Compare commits

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

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

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

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

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

Part of #254
2026-11-03 09:17:43 +04:00
raucao bf23931822 Merge pull request 'Isolate remoteStorage Redis specs from concurrent runs' (#261) from fix/rs-redis-flake into master
CI / Test (push) Successful in 42s
Reviewed-on: #261
2026-11-03 05:13:12 +00:00
raucao 1ac5c7725a Isolate remoteStorage Redis specs from concurrent runs
CI / Test (pull_request) Successful in 42s
Release Drafter / Update release notes draft (pull_request) Successful in 3s
The specs deleted a global authorizations:* pattern from the shared
test Redis DB, so concurrent test processes could wipe each other's
keys mid-example, causing intermittent empty/mismatched set failures.

Drop all global pattern deletes and the now-unused
redis_rs_delete_keys helper. Each spec now reads or asserts only the
exact key derived from its own random token, so runs can no longer
interfere regardless of ordering or concurrency.

Also fixes a vacuous assertion in the #destroy spec, which checked
authorizations:<user.address>:* (never matching the keys, which use
user.cn), to assert the exact key is gone.
2026-11-03 09:10:45 +04:00
raucao dbf76b64b6 Merge pull request 'Docker Compose dev setup: Garage + automated bootstrap' (#250) from chore/docker-compose-setup into master
CI / Test (push) Successful in 46s
Reviewed-on: #250
Reviewed-by: Greg <greg@kosmos.org>
2026-10-12 19:12:20 +00:00
raucao 8c395ce737 Pin platform for amd64-only Docker services
CI / Test (pull_request) Successful in 45s
Release Drafter / Update release notes draft (pull_request) Successful in 10s
2026-10-12 15:33:42 +02:00
raucao 2093f67a64 Merge pull request 'Allow remote access to dev services' (#253) from dev/remote_access into chore/docker-compose-setup
CI / Test (pull_request) Successful in 45s
Reviewed-on: #253
2026-10-06 17:37:49 +00:00
raucao 695977172f Fix Liquor Cabinet performance in dev
CI / Test (pull_request) Failing after 46s
Can't handle enough requests without ThreadPool, so once you hit the
limit, requests take 5s+ (having to wait for the keepalive timeout)
2026-10-06 19:36:26 +02:00
raucao bc37df52a0 Allow remote access to dev services
CI / Test (pull_request) Successful in 46s
Release Drafter / Update release notes draft (pull_request) Successful in 19s
2026-10-06 19:34:24 +02:00
raucao 6279699c11 Switch dev object storage from MinIO to Garage
CI / Test (pull_request) Successful in 43s
MinIO no longer publishes prebuilt community images (quay.io returns
401, Docker Hub 404), so the dev setup cannot pull the image.

Use a single-node Garage instance instead, which auto-creates the
remotestorage bucket and a dev-key1 access key on first start, and
point liquor-cabinet at it.
2026-10-06 13:52:38 +00:00
raucao 0d9c580a0a Automate first-run LDAP and database setup
The documented flow started web (with Solid Queue in Puma) before the
databases existed, and required manually creating the 389ds back-end
and seeding the LDAP directory and databases. Automate it:

- add an ldap-init one-shot service that creates the 389ds back-end if
  it does not exist
- add a web entrypoint that seeds LDAP and the databases on first start,
  and runs db:prepare on later boots
- update README and AGENTS accordingly, including the reset steps
2026-10-06 13:52:38 +00:00
raucao 49c2c9feea Merge pull request 'Gate remoteStorage by serviceEnabled instead of Flipper' (#251) from bugfix/remotestorage-service-enabled into master
CI / Test (push) Failing after 41s
Reviewed-on: #251
2026-10-06 13:52:17 +00:00
raucao 465563d6a6 Merge pull request 'Fix db seeds for Solid Queue' (#249) from fix/seeds-active-job into master
CI / Test (push) Successful in 43s
Reviewed-on: #249
2026-10-06 13:38:20 +00:00
raucao 911d2a5855 Fix db seeds for Solid Queue
CI / Test (pull_request) Successful in 45s
Release Drafter / Update release notes draft (pull_request) Successful in 3s
db/seeds.rb still required "sidekiq/testing" and wrapped seeding in
Sidekiq::Testing.inline!, but Sidekiq is no longer in the bundle after
the Solid Queue migration, so `rails db:setup` failed with a LoadError.

Run the jobs triggered while seeding inline via Active Job instead,
restoring the previous behavior with the adapter the app actually uses.
2026-10-06 13:36:33 +00:00
raucao d8bba02636 Merge pull request 'Stabilize flaky remoteStorage Redis spec' (#252) from bugfix/rs-redis-spec-stabilization into master
CI / Test (push) Failing after 44s
Reviewed-on: #252
2026-10-06 13:36:20 +00:00
raucao 2419982ef5 Stabilize flaky remoteStorage Redis spec
CI / Test (pull_request) Successful in 42s
Release Drafter / Update release notes draft (pull_request) Successful in 2s
"stores a token in redis" asserted an exact count of Redis keys for the
user, but database cleanup only covers SQL and Redis keys accumulate
across examples and spec files. It also relied on a previously-run
example having created the authorization, so it failed depending on test
order and leftover state.

Clear the authorizations:* namespace before each example in the #create
group, create the authorization under test, and assert on its specific
token key instead of a global count.
2026-10-06 15:33:04 +02:00
raucao 8fae099c12 Gate remoteStorage by serviceEnabled instead of Flipper
CI / Test (pull_request) Successful in 2m4s
Release Drafter / Update release notes draft (pull_request) Successful in 2s
The admin "Default services" setting writes the LDAP serviceEnabled
attribute, but remoteStorage access was also gated behind an unused
per-user Flipper flag that nothing ever enabled, so remoteStorage was
inaccessible for everyone.

Gate remoteStorage on service_enabled? like the other services, and make
the admin toggle reflect the LDAP attribute (this also fixes it reading
current_user instead of the user being viewed). E-Mail keeps its Flipper
gate for now.
2026-10-06 15:10:00 +02:00
37 changed files with 1184 additions and 153 deletions

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`)
+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
+142 -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,79 @@ 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,
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
)
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 +169,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
@@ -1,8 +1,7 @@
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]
def index
@@ -23,9 +22,12 @@ class Services::RsAuthsController < Services::BaseController
end
def launch_app
user_address = Rails.env.development? ?
"#{current_user.cn}@localhost:3000" :
current_user.address
user_address =
if Rails.env.development?
"#{current_user.cn}@#{request.host_with_port}"
else
current_user.address
end
launch_url = "#{@auth.launch_url}#remotestorage=#{user_address}"
@@ -34,8 +36,8 @@ class Services::RsAuthsController < Services::BaseController
private
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
+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
+2 -2
View File
@@ -17,8 +17,8 @@ class RemoteStorageAuthorization < ApplicationRecord
before_create :generate_token
before_create :store_token_in_redis
before_create :find_or_create_web_app
after_create :schedule_token_expiry
after_create :notify_user
after_commit :schedule_token_expiry, on: :create
after_commit :notify_user, on: :create
before_destroy :delete_token_from_redis
after_destroy :remove_token_expiry_job
+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,46 @@
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
)
end
auth
end
end
end
@@ -0,0 +1,27 @@
module RsOauthManager
class IssueCode < RsOauthManagerService
def initialize(user:, client_id:, redirect_uri:, permissions:,
code_challenge:, code_challenge_method: "S256",
expire_at: 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
end
def call
RsAuthorizationCode.create!(
user: @user,
client_id: @client_id,
redirect_uri: @redirect_uri,
permissions: @permissions,
permission_expire_at: @expire_at,
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>
+1 -2
View File
@@ -43,8 +43,7 @@
<% 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)]">
+3
View File
@@ -40,6 +40,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) %>
+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"
+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
+2
View File
@@ -115,6 +115,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
+18 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2025_05_28_092931) do
ActiveRecord::Schema[8.1].define(version: 2026_11_02_120000) do
create_table "active_storage_attachments", force: :cascade do |t|
t.bigint "blob_id", null: false
t.datetime "created_at", null: false
@@ -117,6 +117,22 @@ 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.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 +180,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
+42 -14
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,20 +73,28 @@ 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
@@ -79,17 +103,21 @@ services:
- 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
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 +155,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
+283 -28
View File
@@ -128,8 +128,10 @@ RSpec.describe Rs::OauthController, type: :controller do
}
end
it "redirects to the redirect_uri with the existing token" do
expect(response).to redirect_to("https://example.com#access_token=#{@auth.token}")
it "does not reuse the existing token (renders the consent dialog)" do
expect(response.response_code).to eq(200)
expect(response.location).to be_nil
expect(user.remote_storage_authorizations.count).to eq(1)
end
end
@@ -147,36 +149,100 @@ RSpec.describe Rs::OauthController, type: :controller do
expect(response).to redirect_to("https://app.example.com#error=invalid_client")
end
end
end
context "with different redirect_uri" do
before do
get :new, params: {
username: user.cn,
scope: "documents,[photos], contacts:rw videos:r tasks/work/:r",
redirect_uri: "https://example.com/a_new_route",
client_id: "https://example.com"
}
end
it "redirects to the new redirect_uri" do
expect(response).to redirect_to("https://example.com/a_new_route#access_token=#{@auth.token}")
end
context "when response_type is code" do
before do
get :new, params: {
username: user.cn,
redirect_uri: "https://example.com",
client_id: "example.com",
scope: "documents",
response_type: "code",
code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
code_challenge_method: "S256",
state: "foobar123"
}
end
context "with state param given" do
before do
get :new, params: {
username: user.cn,
scope: "documents,[photos], contacts:rw videos:r tasks/work/:r",
redirect_uri: "https://example.com",
client_id: "https://example.com",
state: "foobar123"
}
end
it "renders the consent dialog and exposes the code challenge" do
expect(response.response_code).to eq(200)
expect(assigns["response_type"]).to eq("code")
expect(assigns["code_challenge"]).to eq("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM")
expect(assigns["code_challenge_method"]).to eq("S256")
end
it "redirects to the redirect_uri with token and state" do
expect(response).to redirect_to("https://example.com#access_token=#{@auth.token}&state=foobar123")
end
it "uses a query-based denial url" do
expect(assigns["denial_url"]).to eq("https://example.com?error=access_denied&state=foobar123")
end
end
context "when response_type is code without a code_challenge" do
before do
get :new, params: {
username: user.cn,
redirect_uri: "https://example.com",
client_id: "example.com",
scope: "documents",
response_type: "code",
state: "foobar123"
}
end
it "redirects with invalid_request using a query param" do
expect(response).to redirect_to("https://example.com?error=invalid_request&state=foobar123")
end
end
context "when response_type is code with the plain method" do
before do
get :new, params: {
username: user.cn,
redirect_uri: "https://example.com",
client_id: "example.com",
scope: "documents",
response_type: "code",
code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
code_challenge_method: "plain"
}
end
it "redirects with invalid_request" do
expect(response).to redirect_to("https://example.com?error=invalid_request")
end
end
context "with an unsupported response_type" do
before do
get :new, params: {
username: user.cn,
redirect_uri: "https://example.com",
client_id: "example.com",
scope: "documents",
response_type: "id_token"
}
end
it "redirects with unsupported_response_type" do
expect(response).to redirect_to("https://example.com#error=unsupported_response_type")
end
end
context "with a malformed code_challenge" do
before do
get :new, params: {
username: user.cn,
redirect_uri: "https://example.com",
client_id: "example.com",
scope: "documents",
response_type: "code",
code_challenge: "too-short",
code_challenge_method: "S256"
}
end
it "redirects with invalid_request" do
expect(response).to redirect_to("https://example.com?error=invalid_request")
end
end
@@ -284,6 +350,7 @@ RSpec.describe Rs::OauthController, type: :controller do
after do
user.remote_storage_authorizations.destroy_all
RsAuthorizationCode.destroy_all
end
context "when no valid token exists" do
@@ -412,6 +479,100 @@ 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")
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 +612,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,7 +6,9 @@ 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
@@ -35,6 +37,20 @@ RSpec.describe Services::RsAuthsController, type: :controller do
expect(response).to redirect_to(launch_url)
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: { id: 1 }
end
it "responds with forbidden" do
expect(response).to have_http_status(:forbidden)
end
end
end
end
end
+18
View File
@@ -49,6 +49,24 @@ RSpec.describe 'remoteStorage OAuth Dialog', type: :feature do
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
@@ -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!(
@@ -27,10 +26,9 @@ RSpec.describe RemoteStorageAuthorization, type: :model do
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)
auth
authorizations = redis_rs.smembers(user_auth_keys.first)
authorizations = redis_rs.smembers("authorizations:#{user.cn}:#{auth.token}")
expect(authorizations.sort).to eq(%w(documents photos contacts:rw videos:r tasks/work:r).sort)
end
@@ -49,7 +47,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 +56,7 @@ 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}")).to be(false)
end
context "with expiry set" do
@@ -79,8 +76,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 +168,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 +243,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") }
+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,116 @@
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",
**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(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,29 @@
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
)
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")
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