Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd2b4c9ba5
|
||
|
|
295261b332
|
||
|
|
1e69957777
|
||
|
|
8dd0bf10ce
|
||
|
|
bf23931822
|
||
|
|
1ac5c7725a
|
||
|
|
dbf76b64b6
|
||
|
|
8c395ce737
|
||
|
|
2093f67a64
|
||
|
|
695977172f
|
||
|
|
bc37df52a0
|
||
|
|
6279699c11
|
||
|
|
0d9c580a0a
|
||
|
|
49c2c9feea
|
||
|
|
465563d6a6
|
||
|
|
911d2a5855
|
||
|
|
d8bba02636
|
||
|
|
2419982ef5
|
||
|
|
8fae099c12
|
||
|
|
4536b6505c
|
No files matched your search
@@ -7,13 +7,16 @@ ejabberd, Discourse, Mastodon, remoteStorage, Nostr, LNDHub, and BTCPay.
|
|||||||
## Development environment
|
## Development environment
|
||||||
|
|
||||||
Development runs in Docker Compose — run all commands against the `web` container.
|
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
|
The `web` service runs `bin/dev` (foreman: Puma + Tailwind CSS watcher) and embeds
|
||||||
Solid Queue workers (`SOLID_QUEUE_IN_PUMA=true`).
|
Solid Queue workers (`SOLID_QUEUE_IN_PUMA=true`).
|
||||||
|
|
||||||
First-time LDAP setup (after creating the 389ds backend once):
|
First-time LDAP and database setup is automated: the `ldap-init` service
|
||||||
`docker compose exec ldap dsconf localhost backend create --suffix="dc=kosmos,dc=org" --be_name="dev"`
|
creates the 389ds back-end, then the `web` entrypoint seeds LDAP and the
|
||||||
then `docker compose run web bin/rails ldap:setup`.
|
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`)
|
## Common commands (prefix with `docker compose exec web`)
|
||||||
|
|
||||||
|
|||||||
@@ -14,13 +14,14 @@ so:
|
|||||||
|
|
||||||
1. Make sure [Docker Compose is installed][1] and Docker is running (included in
|
1. Make sure [Docker Compose is installed][1] and Docker is running (included in
|
||||||
Docker Desktop)
|
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
|
(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
|
running for the first time, so you might want to do something else in the
|
||||||
meantime.
|
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`
|
On the first start, the `ldap-init` service creates the 389ds back-end, and the
|
||||||
6. `docker compose run web rails db:setup`
|
`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
|
After these steps, you should have a working Rails app with a handful of test
|
||||||
users running on [http://localhost:3000](http://localhost:3000).
|
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
|
#### LDAP server
|
||||||
|
|
||||||
After creating the Docker container for the first time (or after deleting it),
|
On first start, the `ldap-init` service creates the dirsrv back-end
|
||||||
you need to run the following command once, in order to create the dirsrv
|
automatically, and the `web` container then seeds it with development entries.
|
||||||
back-end:
|
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"
|
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
|
||||||
Now you can seed the back-end with data using this Rails task:
|
|
||||||
|
|
||||||
bundle exec rails ldap:setup
|
|
||||||
|
|
||||||
The setup task will first delete any existing entries in the directory tree
|
The setup task will first delete any existing entries in the directory tree
|
||||||
("dc=kosmos,dc=org"), and then create our development entries.
|
("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
|
to start over with a fresh installation, delete both that volume as well as the
|
||||||
container.
|
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
|
docker compose down -v
|
||||||
respective bucket first. With the `minio` container running (run by default
|
rm -f db/*.sqlite3 tmp/.setup-complete
|
||||||
when using Docker Compose), follow these steps:
|
docker compose up --build
|
||||||
|
|
||||||
* `docker compose up web redis minio liquor-cabinet`
|
#### Garage / remoteStorage
|
||||||
* Head to http://localhost:9001 and log in with user `minioadmin`, password
|
|
||||||
`minioadmin`
|
remoteStorage accounts use the `garage` S3-compatible object store. On first
|
||||||
* Create a new bucket called `remotestorage` (or whatever you
|
start, Garage automatically configures a single-node cluster and creates the
|
||||||
change the `S3_BUCKET` config to)
|
`remotestorage` bucket together with a `dev-key1` access key (secret
|
||||||
* Create a new key with ID "dev-key" and secret "123456789" (or whatever you
|
`1234567890123456`), so no manual setup is required.
|
||||||
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.
|
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
|
### Adding npm modules to use with Stimulus controllers
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
class Rs::OauthController < ApplicationController
|
class Rs::OauthController < ApplicationController
|
||||||
|
skip_before_action :verify_authenticity_token, only: :token
|
||||||
|
|
||||||
prepend_before_action :assert_redirect_uri, only: [:new, :create]
|
prepend_before_action :assert_redirect_uri, only: [:new, :create]
|
||||||
before_action :require_signed_in_with_username, only: :new
|
before_action :require_signed_in_with_username, only: :new
|
||||||
before_action :authenticate_user!, only: :create
|
before_action :authenticate_user!, only: :create
|
||||||
|
before_action :set_cors_headers, only: :token
|
||||||
|
|
||||||
def new
|
def new
|
||||||
@user = User.where(cn: params[:username].downcase, ou: Setting.primary_domain).first
|
@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]
|
@redirect_uri = params[:redirect_uri]
|
||||||
@client_id = params[:client_id]
|
@client_id = params[:client_id]
|
||||||
@state = params[:state]
|
@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?
|
@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],
|
@expire_at_dates = [["Never", nil],
|
||||||
["In 1 month", 1.month.from_now],
|
["In 1 month", 1.month.from_now],
|
||||||
@@ -20,35 +26,45 @@ class Rs::OauthController < ApplicationController
|
|||||||
unless current_user == @user
|
unless current_user == @user
|
||||||
sign_out :user
|
sign_out :user
|
||||||
|
|
||||||
redirect_to new_rs_oauth_url(@user.cn,
|
redirect_params = {
|
||||||
scope: params[:scope],
|
scope: params[:scope],
|
||||||
redirect_uri: params[:redirect_uri],
|
redirect_uri: params[:redirect_uri],
|
||||||
client_id: params[:client_id],
|
client_id: params[:client_id],
|
||||||
state: params[:state])
|
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
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
unless @client_id.present?
|
unless @client_id.present?
|
||||||
redirect_to(url_with_state("#{@redirect_uri}#error=invalid_request", @state),
|
error_redirect(@redirect_uri, "error=invalid_request", @state) and return
|
||||||
allow_other_host: true) and return
|
|
||||||
end
|
end
|
||||||
|
|
||||||
if @scopes.empty?
|
if @scopes.empty?
|
||||||
redirect_to(url_with_state("#{@redirect_uri}#error=invalid_scope", @state),
|
error_redirect(@redirect_uri, "error=invalid_scope", @state) and return
|
||||||
allow_other_host: true) and return
|
|
||||||
end
|
end
|
||||||
|
|
||||||
unless hostname_of(@client_id) == hostname_of(@redirect_uri)
|
unless hostname_of(@client_id) == hostname_of(@redirect_uri)
|
||||||
redirect_to(url_with_state("#{@redirect_uri}#error=invalid_client", @state),
|
error_redirect(@redirect_uri, "error=invalid_client", @state) and return
|
||||||
allow_other_host: true) 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
|
end
|
||||||
|
|
||||||
@client_id.gsub!(/http(s)?:\/\//, "")
|
@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
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
@@ -57,6 +73,7 @@ class Rs::OauthController < ApplicationController
|
|||||||
http_status :forbidden and return
|
http_status :forbidden and return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
response_type = params[:response_type].presence || "token"
|
||||||
permissions = parse_scopes params[:scope]
|
permissions = parse_scopes params[:scope]
|
||||||
redirect_uri = params[:redirect_uri].presence
|
redirect_uri = params[:redirect_uri].presence
|
||||||
client_id = params[:client_id].presence
|
client_id = params[:client_id].presence
|
||||||
@@ -64,32 +81,79 @@ class Rs::OauthController < ApplicationController
|
|||||||
expire_at = params[:expire_at].presence
|
expire_at = params[:expire_at].presence
|
||||||
|
|
||||||
if permissions.empty?
|
if permissions.empty?
|
||||||
redirect_to(url_with_state("#{redirect_uri}#error=invalid_scope", state),
|
error_redirect(redirect_uri, "error=invalid_scope", state, response_type) and return
|
||||||
allow_other_host: true) and return
|
|
||||||
end
|
end
|
||||||
|
|
||||||
unless client_id.present?
|
unless client_id.present?
|
||||||
redirect_to(url_with_state("#{redirect_uri}#error=invalid_request", state),
|
error_redirect(redirect_uri, "error=invalid_request", state, response_type) and return
|
||||||
allow_other_host: true) and return
|
|
||||||
end
|
end
|
||||||
|
|
||||||
unless hostname_of(client_id) == hostname_of(redirect_uri)
|
unless hostname_of(client_id) == hostname_of(redirect_uri)
|
||||||
redirect_to(url_with_state("#{redirect_uri}#error=invalid_client", state),
|
error_redirect(redirect_uri, "error=invalid_client", state, response_type) and return
|
||||||
allow_other_host: true) and return
|
end
|
||||||
|
|
||||||
|
unless %w[token code].include?(response_type)
|
||||||
|
error_redirect(redirect_uri, "error=unsupported_response_type", state, response_type) and return
|
||||||
end
|
end
|
||||||
|
|
||||||
client_id.gsub!(/http(s)?:\/\//, "")
|
client_id.gsub!(/http(s)?:\/\//, "")
|
||||||
|
|
||||||
auth = current_user.remote_storage_authorizations.create!(
|
if response_type == "code"
|
||||||
permissions: permissions,
|
challenge = params[:code_challenge].presence
|
||||||
client_id: client_id,
|
method = params[:code_challenge_method].presence || "S256"
|
||||||
redirect_uri: redirect_uri,
|
|
||||||
app_name: client_id,
|
unless valid_code_challenge?(challenge, method)
|
||||||
expire_at: expire_at
|
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),
|
render_token(token_response(auth))
|
||||||
allow_other_host: true
|
rescue RsOauthManagerService::InvalidGrant
|
||||||
|
render_token_error("invalid_grant")
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
@@ -105,24 +169,60 @@ class Rs::OauthController < ApplicationController
|
|||||||
end
|
end
|
||||||
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)
|
def hostname_of(uri)
|
||||||
uri.gsub(/http(s)?:\/\//, "").split(":")[0].split("/")[0]
|
RsOauthManagerService.hostname_of(uri)
|
||||||
end
|
end
|
||||||
|
|
||||||
def parse_scopes(scope_string)
|
def parse_scopes(scope_string)
|
||||||
return [] if scope_string.blank?
|
RsOauthManagerService.parse_scopes(scope_string)
|
||||||
|
end
|
||||||
|
|
||||||
scopes = scope_string.
|
def error_redirect(redirect_uri, error, state, response_type = @response_type)
|
||||||
gsub(/\[|\]/, "").
|
redirect_to error_url(redirect_uri, error, state, response_type),
|
||||||
gsub(/\,/, " ").
|
allow_other_host: true
|
||||||
gsub(/\/:/, ":").
|
end
|
||||||
split(/\s/).map(&:strip).
|
|
||||||
reject(&:empty?)
|
|
||||||
|
|
||||||
scopes = [":r"] if scopes.include?("*:r")
|
def error_url(redirect_uri, error, state, response_type = @response_type)
|
||||||
scopes = [":rw"] if scopes.include?("*:rw")
|
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
|
end
|
||||||
|
|
||||||
def url_with_state(url, state)
|
def url_with_state(url, state)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
class Services::RemotestorageController < Services::BaseController
|
class Services::RemotestorageController < Services::BaseController
|
||||||
before_action :authenticate_user!
|
before_action :authenticate_user!
|
||||||
before_action :require_service_available
|
before_action :require_service_available
|
||||||
before_action :require_feature_enabled
|
before_action :require_service_enabled
|
||||||
|
|
||||||
# Dashboard
|
# Dashboard
|
||||||
def show
|
def show
|
||||||
@@ -17,8 +17,8 @@ class Services::RemotestorageController < Services::BaseController
|
|||||||
http_status :not_found unless Setting.remotestorage_enabled?
|
http_status :not_found unless Setting.remotestorage_enabled?
|
||||||
end
|
end
|
||||||
|
|
||||||
def require_feature_enabled
|
def require_service_enabled
|
||||||
unless Flipper.enabled?(:remotestorage, current_user)
|
unless current_user.service_enabled?(:remotestorage)
|
||||||
http_status :forbidden
|
http_status :forbidden
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
class Services::RsAuthsController < Services::BaseController
|
class Services::RsAuthsController < Services::BaseController
|
||||||
before_action :authenticate_user!
|
before_action :authenticate_user!
|
||||||
before_action :require_feature_enabled
|
before_action :require_service_enabled
|
||||||
before_action :require_service_available
|
before_action :require_service_available
|
||||||
# before_action :require_service_enabled
|
|
||||||
before_action :find_rs_auth, only: [:destroy, :launch_app]
|
before_action :find_rs_auth, only: [:destroy, :launch_app]
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@@ -23,9 +22,12 @@ class Services::RsAuthsController < Services::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def launch_app
|
def launch_app
|
||||||
user_address = Rails.env.development? ?
|
user_address =
|
||||||
"#{current_user.cn}@localhost:3000" :
|
if Rails.env.development?
|
||||||
current_user.address
|
"#{current_user.cn}@#{request.host_with_port}"
|
||||||
|
else
|
||||||
|
current_user.address
|
||||||
|
end
|
||||||
|
|
||||||
launch_url = "#{@auth.launch_url}#remotestorage=#{user_address}"
|
launch_url = "#{@auth.launch_url}#remotestorage=#{user_address}"
|
||||||
|
|
||||||
@@ -34,8 +36,8 @@ class Services::RsAuthsController < Services::BaseController
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def require_feature_enabled
|
def require_service_enabled
|
||||||
unless Flipper.enabled?(:remotestorage, current_user)
|
unless current_user.service_enabled?(:remotestorage)
|
||||||
http_status :forbidden
|
http_status :forbidden
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -88,19 +88,42 @@ class WebfingerController < WellKnownController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def remotestorage_link
|
def remotestorage_link
|
||||||
auth_url = new_rs_oauth_url(@username, host: Setting.rs_accounts_domain)
|
auth_url =
|
||||||
storage_url = "#{Setting.rs_storage_url}/#{@username}"
|
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",
|
rel: "http://tools.ietf.org/id/draft-dejong-remotestorage",
|
||||||
href: storage_url,
|
href: storage_url,
|
||||||
properties: {
|
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-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/rfc6750#section-2.3" => nil, # access token via a HTTP query parameter
|
||||||
"http://tools.ietf.org/html/rfc7233": "GET", # content range requests
|
"http://tools.ietf.org/html/rfc7233": "GET", # content range requests
|
||||||
"http://remotestorage.io/spec/web-authoring": nil
|
"http://remotestorage.io/spec/web-authoring": nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
end
|
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
|
end
|
||||||
@@ -17,8 +17,8 @@ class RemoteStorageAuthorization < ApplicationRecord
|
|||||||
before_create :generate_token
|
before_create :generate_token
|
||||||
before_create :store_token_in_redis
|
before_create :store_token_in_redis
|
||||||
before_create :find_or_create_web_app
|
before_create :find_or_create_web_app
|
||||||
after_create :schedule_token_expiry
|
after_commit :schedule_token_expiry, on: :create
|
||||||
after_create :notify_user
|
after_commit :notify_user, on: :create
|
||||||
before_destroy :delete_token_from_redis
|
before_destroy :delete_token_from_redis
|
||||||
after_destroy :remove_token_expiry_job
|
after_destroy :remove_token_expiry_job
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -305,7 +305,7 @@
|
|||||||
<td>remoteStorage</td>
|
<td>remoteStorage</td>
|
||||||
<td>
|
<td>
|
||||||
<%= render FormElements::ToggleComponent.new(
|
<%= render FormElements::ToggleComponent.new(
|
||||||
enabled: Flipper.enabled?(:remotestorage, current_user) && @services_enabled.include?("remotestorage"),
|
enabled: @services_enabled.include?("remotestorage"),
|
||||||
input_enabled: false
|
input_enabled: false
|
||||||
) %>
|
) %>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -43,8 +43,7 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% if Setting.remotestorage_enabled? &&
|
<% if Setting.remotestorage_enabled? %>
|
||||||
Flipper.enabled?(:remotestorage, current_user) %>
|
|
||||||
<div class="border border-gray-300 rounded-md hover:border-gray-400
|
<div class="border border-gray-300 rounded-md hover:border-gray-400
|
||||||
bg-[length:80%] bg-[center_top_-156px] bg-no-repeat
|
bg-[length:80%] bg-[center_top_-156px] bg-no-repeat
|
||||||
bg-[url(/img/logos/icon_remotestorage.svg)]">
|
bg-[url(/img/logos/icon_remotestorage.svg)]">
|
||||||
|
|||||||
@@ -40,6 +40,9 @@
|
|||||||
<%= f.hidden_field :user_id, value: @user.id %>
|
<%= f.hidden_field :user_id, value: @user.id %>
|
||||||
<%= f.hidden_field :client_id, value: @client_id %>
|
<%= f.hidden_field :client_id, value: @client_id %>
|
||||||
<%= f.hidden_field :state, value: @state %>
|
<%= 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">
|
<p class="mt-8 mb-6">
|
||||||
<%= f.label :expire_at, "Permission expires:", class: "mr-1.5" %>
|
<%= f.label :expire_at, "Permission expires:", class: "mr-1.5" %>
|
||||||
<%= f.select :expire_at, options_for_select(@expire_at_dates) %>
|
<%= f.select :expire_at, options_for_select(@expire_at_dates) %>
|
||||||
|
|||||||
@@ -25,8 +25,7 @@
|
|||||||
active: @settings_section.to_s == "lightning"
|
active: @settings_section.to_s == "lightning"
|
||||||
) %>
|
) %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% if Setting.remotestorage_enabled? &&
|
<% if Setting.remotestorage_enabled? %>
|
||||||
Flipper.enabled?(:remotestorage, current_user) %>
|
|
||||||
<%= render SidenavLinkComponent.new(
|
<%= render SidenavLinkComponent.new(
|
||||||
name: "Storage", path: setting_path(:remotestorage), icon: "remotestorage",
|
name: "Storage", path: setting_path(:remotestorage), icon: "remotestorage",
|
||||||
active: @settings_section.to_s == "remotestorage"
|
active: @settings_section.to_s == "remotestorage"
|
||||||
|
|||||||
@@ -8,3 +8,8 @@
|
|||||||
# command: "SoftDeletedRecord.due.delete_all"
|
# command: "SoftDeletedRecord.due.delete_all"
|
||||||
# priority: 2
|
# priority: 2
|
||||||
# schedule: at 5am every day
|
# schedule: at 5am every day
|
||||||
|
|
||||||
|
production:
|
||||||
|
purge_rs_authorization_codes:
|
||||||
|
command: "RsAuthorizationCode.where(expires_at: ..1.hour.ago).delete_all"
|
||||||
|
schedule: every hour
|
||||||
@@ -115,6 +115,8 @@ Rails.application.routes.draw do
|
|||||||
end
|
end
|
||||||
|
|
||||||
namespace :rs do
|
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: {
|
resource :oauth, only: [:new, :create], path_names: {
|
||||||
new: ':username', create: ':username'
|
new: ':username', create: ':username'
|
||||||
}, controller: 'oauth'
|
}, 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
@@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# 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|
|
create_table "active_storage_attachments", force: :cascade do |t|
|
||||||
t.bigint "blob_id", null: false
|
t.bigint "blob_id", null: false
|
||||||
t.datetime "created_at", 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"
|
t.index ["web_app_id"], name: "index_remote_storage_authorizations_on_web_app_id"
|
||||||
end
|
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|
|
create_table "settings", force: :cascade do |t|
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "updated_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 "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", "app_catalog_web_apps", column: "web_app_id"
|
||||||
add_foreign_key "remote_storage_authorizations", "users"
|
add_foreign_key "remote_storage_authorizations", "users"
|
||||||
|
add_foreign_key "rs_authorization_codes", "users"
|
||||||
add_foreign_key "zaps", "users"
|
add_foreign_key "zaps", "users"
|
||||||
end
|
end
|
||||||
+6
-3
@@ -1,8 +1,9 @@
|
|||||||
require 'sidekiq/testing'
|
|
||||||
|
|
||||||
ldap = LdapService.new
|
ldap = LdapService.new
|
||||||
|
|
||||||
Sidekiq::Testing.inline! do
|
original_queue_adapter = ActiveJob::Base.queue_adapter
|
||||||
|
ActiveJob::Base.queue_adapter = :inline
|
||||||
|
|
||||||
|
begin
|
||||||
ldap.delete_all_users!
|
ldap.delete_all_users!
|
||||||
|
|
||||||
puts "Create user: admin"
|
puts "Create user: admin"
|
||||||
@@ -25,4 +26,6 @@ Sidekiq::Testing.inline! do
|
|||||||
password: "user is user", confirmed: true
|
password: "user is user", confirmed: true
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
ensure
|
||||||
|
ActiveJob::Base.queue_adapter = original_queue_adapter
|
||||||
end
|
end
|
||||||
+42
-14
@@ -1,6 +1,7 @@
|
|||||||
services:
|
services:
|
||||||
ldap:
|
ldap:
|
||||||
image: 4teamwork/389ds:latest
|
image: 4teamwork/389ds:latest
|
||||||
|
platform: linux/amd64
|
||||||
volumes:
|
volumes:
|
||||||
- 389ds-data:/data
|
- 389ds-data:/data
|
||||||
networks:
|
networks:
|
||||||
@@ -12,6 +13,21 @@ services:
|
|||||||
DS_DM_PASSWORD: passthebutter
|
DS_DM_PASSWORD: passthebutter
|
||||||
SUFFIX_NAME: "dc=kosmos,dc=org"
|
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:
|
redis:
|
||||||
restart: always
|
restart: always
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
@@ -26,7 +42,7 @@ services:
|
|||||||
web:
|
web:
|
||||||
build: .
|
build: .
|
||||||
tty: true
|
tty: true
|
||||||
command: bash -c "rm -f /akkounts/tmp/pids/server.pid; bin/dev"
|
command: ["bash", "docker/web-entrypoint.sh"]
|
||||||
volumes:
|
volumes:
|
||||||
- .:/akkounts
|
- .:/akkounts
|
||||||
- /akkounts/node_modules
|
- /akkounts/node_modules
|
||||||
@@ -57,20 +73,28 @@ services:
|
|||||||
NOSTR_PRIVATE_KEY: 7c3ef7e448505f0615137af38569d01807d3b05b5005d5ecf8aaafcd40323cea
|
NOSTR_PRIVATE_KEY: 7c3ef7e448505f0615137af38569d01807d3b05b5005d5ecf8aaafcd40323cea
|
||||||
NOSTR_RELAY_URL: ws://strfry:7777
|
NOSTR_RELAY_URL: ws://strfry:7777
|
||||||
depends_on:
|
depends_on:
|
||||||
- ldap
|
ldap:
|
||||||
- redis
|
condition: service_started
|
||||||
|
ldap-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
redis:
|
||||||
|
condition: service_started
|
||||||
|
|
||||||
minio:
|
garage:
|
||||||
image: quay.io/minio/minio:latest
|
image: dxflrs/garage:v2.4.1
|
||||||
command: "server /data --console-address ':9001'"
|
command: ["/garage", "server", "--single-node", "--default-bucket"]
|
||||||
networks:
|
networks:
|
||||||
- external_network
|
- external_network
|
||||||
- internal_network
|
- internal_network
|
||||||
ports:
|
ports:
|
||||||
- "9000:9000"
|
- "3900:3900"
|
||||||
- "9001:9001"
|
|
||||||
volumes:
|
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:
|
liquor-cabinet:
|
||||||
image: gitea.kosmos.org/5apps/liquor-cabinet:2.0.0-rc.1
|
image: gitea.kosmos.org/5apps/liquor-cabinet:2.0.0-rc.1
|
||||||
@@ -79,17 +103,21 @@ services:
|
|||||||
- internal_network
|
- internal_network
|
||||||
ports:
|
ports:
|
||||||
- "4567:4567"
|
- "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:
|
environment:
|
||||||
RACK_ENV: staging
|
RACK_ENV: staging
|
||||||
REDIS_HOST: redis
|
REDIS_HOST: redis
|
||||||
REDIS_PORT: 6379
|
REDIS_PORT: 6379
|
||||||
REDIS_DB: 1
|
REDIS_DB: 1
|
||||||
S3_ENDPOINT: http://minio:9000
|
S3_ENDPOINT: http://garage:3900
|
||||||
S3_ACCESS_KEY: dev-key
|
S3_REGION: garage
|
||||||
S3_SECRET_KEY: 123456789
|
S3_ACCESS_KEY: dev-key1
|
||||||
|
S3_SECRET_KEY: "1234567890123456"
|
||||||
S3_BUCKET: remotestorage
|
S3_BUCKET: remotestorage
|
||||||
depends_on:
|
depends_on:
|
||||||
- minio
|
- garage
|
||||||
- redis
|
- redis
|
||||||
|
|
||||||
strfry:
|
strfry:
|
||||||
@@ -127,7 +155,7 @@ networks:
|
|||||||
volumes:
|
volumes:
|
||||||
389ds-data:
|
389ds-data:
|
||||||
driver: local
|
driver: local
|
||||||
minio-data:
|
garage-data:
|
||||||
driver: local
|
driver: local
|
||||||
redis-data:
|
redis-data:
|
||||||
driver: local
|
driver: local
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
Rainbows! do
|
||||||
|
use :ThreadPool
|
||||||
|
worker_connections 16
|
||||||
|
client_max_body_size 100 * 1024 * 1024
|
||||||
|
end
|
||||||
@@ -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
|
||||||
+1
-1
@@ -11,7 +11,7 @@
|
|||||||
"postcss-preset-env": "^7.8.3",
|
"postcss-preset-env": "^7.8.3",
|
||||||
"tailwindcss": "^3.4.0"
|
"tailwindcss": "^3.4.0"
|
||||||
},
|
},
|
||||||
"version": "0.11.0",
|
"version": "0.11.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build:css:tailwind": "bun ./node_modules/tailwindcss/lib/cli.js --postcss -i ./app/assets/stylesheets/application.tailwind.css -o ./app/assets/builds/application.css",
|
"build:css:tailwind": "bun ./node_modules/tailwindcss/lib/cli.js --postcss -i ./app/assets/stylesheets/application.tailwind.css -o ./app/assets/builds/application.css",
|
||||||
"build:css": "bun run build:css:tailwind"
|
"build:css": "bun run build:css:tailwind"
|
||||||
|
|||||||
@@ -128,8 +128,10 @@ RSpec.describe Rs::OauthController, type: :controller do
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
it "redirects to the redirect_uri with the existing token" do
|
it "does not reuse the existing token (renders the consent dialog)" do
|
||||||
expect(response).to redirect_to("https://example.com#access_token=#{@auth.token}")
|
expect(response.response_code).to eq(200)
|
||||||
|
expect(response.location).to be_nil
|
||||||
|
expect(user.remote_storage_authorizations.count).to eq(1)
|
||||||
end
|
end
|
||||||
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")
|
expect(response).to redirect_to("https://app.example.com#error=invalid_client")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
context "with different redirect_uri" do
|
context "when response_type is code" do
|
||||||
before do
|
before do
|
||||||
get :new, params: {
|
get :new, params: {
|
||||||
username: user.cn,
|
username: user.cn,
|
||||||
scope: "documents,[photos], contacts:rw videos:r tasks/work/:r",
|
redirect_uri: "https://example.com",
|
||||||
redirect_uri: "https://example.com/a_new_route",
|
client_id: "example.com",
|
||||||
client_id: "https://example.com"
|
scope: "documents",
|
||||||
}
|
response_type: "code",
|
||||||
end
|
code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
|
||||||
|
code_challenge_method: "S256",
|
||||||
it "redirects to the new redirect_uri" do
|
state: "foobar123"
|
||||||
expect(response).to redirect_to("https://example.com/a_new_route#access_token=#{@auth.token}")
|
}
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
context "with state param given" do
|
it "renders the consent dialog and exposes the code challenge" do
|
||||||
before do
|
expect(response.response_code).to eq(200)
|
||||||
get :new, params: {
|
expect(assigns["response_type"]).to eq("code")
|
||||||
username: user.cn,
|
expect(assigns["code_challenge"]).to eq("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM")
|
||||||
scope: "documents,[photos], contacts:rw videos:r tasks/work/:r",
|
expect(assigns["code_challenge_method"]).to eq("S256")
|
||||||
redirect_uri: "https://example.com",
|
end
|
||||||
client_id: "https://example.com",
|
|
||||||
state: "foobar123"
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
it "redirects to the redirect_uri with token and state" do
|
it "uses a query-based denial url" do
|
||||||
expect(response).to redirect_to("https://example.com#access_token=#{@auth.token}&state=foobar123")
|
expect(assigns["denial_url"]).to eq("https://example.com?error=access_denied&state=foobar123")
|
||||||
end
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -284,6 +350,7 @@ RSpec.describe Rs::OauthController, type: :controller do
|
|||||||
|
|
||||||
after do
|
after do
|
||||||
user.remote_storage_authorizations.destroy_all
|
user.remote_storage_authorizations.destroy_all
|
||||||
|
RsAuthorizationCode.destroy_all
|
||||||
end
|
end
|
||||||
|
|
||||||
context "when no valid token exists" do
|
context "when no valid token exists" do
|
||||||
@@ -412,6 +479,100 @@ RSpec.describe Rs::OauthController, type: :controller do
|
|||||||
end
|
end
|
||||||
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
|
context "when the user_id is different from the signed in user" do
|
||||||
before do
|
before do
|
||||||
post :create, params: {
|
post :create, params: {
|
||||||
@@ -451,4 +612,98 @@ RSpec.describe Rs::OauthController, type: :controller do
|
|||||||
end
|
end
|
||||||
end
|
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
|
end
|
||||||
@@ -6,7 +6,9 @@ RSpec.describe Services::RsAuthsController, type: :controller do
|
|||||||
before do
|
before do
|
||||||
allow_any_instance_of(AppCatalog::WebApp).to receive(:update_metadata).and_return(true)
|
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(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
|
end
|
||||||
|
|
||||||
describe "GET /services/storage/rs_auths/:id/launch_app" do
|
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)
|
expect(response).to redirect_to(launch_url)
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
end
|
end
|
||||||
@@ -49,6 +49,24 @@ RSpec.describe 'remoteStorage OAuth Dialog', type: :feature do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context "with PKCE parameters" do
|
||||||
|
before do
|
||||||
|
visit new_rs_oauth_path(username: user.cn,
|
||||||
|
redirect_uri: "http://example.com",
|
||||||
|
client_id: "http://example.com",
|
||||||
|
scope: "documents:rw",
|
||||||
|
response_type: "code",
|
||||||
|
code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
|
||||||
|
code_challenge_method: "S256")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "includes the PKCE parameters in the consent form" do
|
||||||
|
expect(page).to have_selector("input[name='response_type'][value='code']", visible: false)
|
||||||
|
expect(page).to have_selector("input[name='code_challenge'][value='E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM']", visible: false)
|
||||||
|
expect(page).to have_selector("input[name='code_challenge_method'][value='S256']", visible: false)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context "when signed out" do
|
context "when signed out" do
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ RSpec.describe RemoteStorageAuthorization, type: :model do
|
|||||||
|
|
||||||
describe "#create" do
|
describe "#create" do
|
||||||
after(:each) { clear_enqueued_jobs }
|
after(:each) { clear_enqueued_jobs }
|
||||||
after(:all) { redis_rs_delete_keys("authorizations:*") }
|
|
||||||
|
|
||||||
let(:auth) do
|
let(:auth) do
|
||||||
user.remote_storage_authorizations.create!(
|
user.remote_storage_authorizations.create!(
|
||||||
@@ -27,10 +26,9 @@ RSpec.describe RemoteStorageAuthorization, type: :model do
|
|||||||
end
|
end
|
||||||
|
|
||||||
it "stores a token in redis" do
|
it "stores a token in redis" do
|
||||||
user_auth_keys = redis_rs.keys("authorizations:#{user.cn}:*")
|
auth
|
||||||
expect(user_auth_keys.length).to eq(1)
|
|
||||||
|
|
||||||
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)
|
expect(authorizations.sort).to eq(%w(documents photos contacts:rw videos:r tasks/work:r).sort)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -49,7 +47,6 @@ RSpec.describe RemoteStorageAuthorization, type: :model do
|
|||||||
|
|
||||||
describe "#destroy" do
|
describe "#destroy" do
|
||||||
after(:each) { clear_enqueued_jobs }
|
after(:each) { clear_enqueued_jobs }
|
||||||
after(:all) { redis_rs_delete_keys("authorizations:*") }
|
|
||||||
|
|
||||||
it "removes the token from redis" do
|
it "removes the token from redis" do
|
||||||
auth = user.remote_storage_authorizations.create!(
|
auth = user.remote_storage_authorizations.create!(
|
||||||
@@ -59,7 +56,7 @@ RSpec.describe RemoteStorageAuthorization, type: :model do
|
|||||||
)
|
)
|
||||||
auth.destroy!
|
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
|
end
|
||||||
|
|
||||||
context "with expiry set" do
|
context "with expiry set" do
|
||||||
@@ -79,8 +76,6 @@ RSpec.describe RemoteStorageAuthorization, type: :model do
|
|||||||
|
|
||||||
describe "#find_or_create_web_app" do
|
describe "#find_or_create_web_app" do
|
||||||
context "with origin that looks hosted" do
|
context "with origin that looks hosted" do
|
||||||
after(:all) { redis_rs_delete_keys("authorizations:*") }
|
|
||||||
|
|
||||||
let(:auth) do
|
let(:auth) do
|
||||||
user.remote_storage_authorizations.create!(
|
user.remote_storage_authorizations.create!(
|
||||||
permissions: %w(documents:rw),
|
permissions: %w(documents:rw),
|
||||||
@@ -173,8 +168,6 @@ RSpec.describe RemoteStorageAuthorization, type: :model do
|
|||||||
end
|
end
|
||||||
|
|
||||||
describe "#launch_url" do
|
describe "#launch_url" do
|
||||||
after(:all) { redis_rs_delete_keys("authorizations:*") }
|
|
||||||
|
|
||||||
context "without start URL" do
|
context "without start URL" do
|
||||||
before do
|
before do
|
||||||
AppCatalog::WebApp.create!(
|
AppCatalog::WebApp.create!(
|
||||||
@@ -250,7 +243,6 @@ RSpec.describe RemoteStorageAuthorization, type: :model do
|
|||||||
include ActiveJob::TestHelper
|
include ActiveJob::TestHelper
|
||||||
|
|
||||||
after(:each) { clear_enqueued_jobs }
|
after(:each) { clear_enqueued_jobs }
|
||||||
after(:all) { redis_rs_delete_keys("authorizations:*") }
|
|
||||||
|
|
||||||
before { allow(user).to receive(:display_name).and_return("Jimmy") }
|
before { allow(user).to receive(:display_name).and_return("Jimmy") }
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -133,6 +133,15 @@ RSpec.describe "WebFinger", type: :request do
|
|||||||
|
|
||||||
oauth_url = rs_link["properties"]["http://tools.ietf.org/html/rfc6749#section-4.2"]
|
oauth_url = rs_link["properties"]["http://tools.ietf.org/html/rfc6749#section-4.2"]
|
||||||
expect(oauth_url).to eql("http://localhost/rs/oauth/tony")
|
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
|
end
|
||||||
|
|
||||||
it "returns CORS headers" do
|
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
|
||||||
@@ -1,8 +1,3 @@
|
|||||||
def redis_rs
|
def redis_rs
|
||||||
@redis_rs ||= Redis.new(url: Setting.rs_redis_url)
|
@redis_rs ||= Redis.new(url: Setting.rs_redis_url)
|
||||||
end
|
end
|
||||||
|
|
||||||
def redis_rs_delete_keys(pattern)
|
|
||||||
keys = redis_rs.keys(pattern)
|
|
||||||
redis_rs.del(*keys)
|
|
||||||
end
|
|
||||||
Reference in new issue
Block a user