]> git.openstreetmap.org Git - rails.git/commitdiff
Merge remote-tracking branch 'upstream/pull/3177'
authorTom Hughes <tom@compton.nu>
Thu, 24 Jun 2021 07:43:18 +0000 (08:43 +0100)
committerTom Hughes <tom@compton.nu>
Thu, 24 Jun 2021 07:43:18 +0000 (08:43 +0100)
41 files changed:
.rubocop.yml
.rubocop_todo.yml
Gemfile
Gemfile.lock
app/abilities/ability.rb
app/abilities/api_capability.rb
app/controllers/api_controller.rb
app/controllers/application_controller.rb
app/controllers/oauth2_applications_controller.rb [new file with mode: 0644]
app/controllers/oauth2_authorizations_controller.rb [new file with mode: 0644]
app/controllers/oauth2_authorized_applications_controller.rb [new file with mode: 0644]
app/models/access_token.rb
app/models/client_application.rb
app/models/user.rb
app/views/oauth2_applications/_application.html.erb [new file with mode: 0644]
app/views/oauth2_applications/_form.html.erb [new file with mode: 0644]
app/views/oauth2_applications/edit.html.erb [new file with mode: 0644]
app/views/oauth2_applications/index.html.erb [new file with mode: 0644]
app/views/oauth2_applications/new.html.erb [new file with mode: 0644]
app/views/oauth2_applications/not_found.html.erb [new file with mode: 0644]
app/views/oauth2_applications/show.html.erb [new file with mode: 0644]
app/views/oauth2_authorizations/error.html.erb [new file with mode: 0644]
app/views/oauth2_authorizations/new.html.erb [new file with mode: 0644]
app/views/oauth2_authorizations/show.html.erb [new file with mode: 0644]
app/views/oauth2_authorized_applications/_application.html.erb [new file with mode: 0644]
app/views/oauth2_authorized_applications/index.html.erb [new file with mode: 0644]
app/views/users/account.html.erb
config/initializers/doorkeeper.rb [new file with mode: 0644]
config/locales/en.yml
config/routes.rb
db/migrate/20201004105659_create_doorkeeper_tables.rb [new file with mode: 0644]
db/structure.sql
lib/oauth.rb [new file with mode: 0644]
test/controllers/oauth2_applications_controller_test.rb [new file with mode: 0644]
test/controllers/oauth2_authorizations_controller_test.rb [new file with mode: 0644]
test/controllers/oauth2_authorized_applications_controller_test.rb [new file with mode: 0644]
test/factories/oauth_access_grant.rb [new file with mode: 0644]
test/factories/oauth_access_token.rb [new file with mode: 0644]
test/factories/oauth_applications.rb [new file with mode: 0644]
test/integration/oauth2_test.rb [new file with mode: 0644]
test/test_helper.rb

index 49fba1d0c64ac68501da202c0bad105599bdbe1f..aa60df305e2613659f470ffcafb57dce3c22941e 100644 (file)
@@ -58,6 +58,9 @@ Rails/HttpPositionalArguments:
 Rails/InverseOf:
   Enabled: false
 
+Rails/ReflectionClassName:
+  Enabled: false
+
 Rails/SkipsModelValidations:
   Exclude:
     - 'db/migrate/*.rb'
index f7ee886c99cbf23ebec6cd5fa465707c2f2a589c..a0b57f586badc5006e2b9ba7f7f52395a526b374 100644 (file)
@@ -142,6 +142,10 @@ Rails/HelperInstanceVariable:
   Exclude:
     - 'app/helpers/title_helper.rb'
 
+Rails/LexicallyScopedActionFilter:
+  Exclude:
+    - 'app/controllers/oauth2_applications_controller.rb'
+
 # Offense count: 5
 # Configuration parameters: Include.
 # Include: db/migrate/*.rb
diff --git a/Gemfile b/Gemfile
index 10e34638eb8d294706f9d7cf8ca543418ec28380..cdc5c7e269503b8dff605afdf3db08c44bedf756 100644 (file)
--- a/Gemfile
+++ b/Gemfile
@@ -69,6 +69,10 @@ gem "omniauth-openid"
 gem "omniauth-rails_csrf_protection", "~> 1.0"
 gem "omniauth-windowslive"
 
+# Doorkeeper for OAuth2
+gem "doorkeeper"
+gem "doorkeeper-i18n"
+
 # Markdown formatting support
 gem "kramdown"
 
index 4f74eda016c84074898937e20a479d30691156c9..d7b7f441e7b6adae1d6826188440a016a0d8fe42 100644 (file)
@@ -151,6 +151,10 @@ GEM
       activerecord (>= 3.0, < 6.2)
       delayed_job (>= 3.0, < 5)
     docile (1.4.0)
+    doorkeeper (5.5.1)
+      railties (>= 5)
+    doorkeeper-i18n (5.2.2)
+      doorkeeper (>= 5.2)
     dry-configurable (0.12.1)
       concurrent-ruby (~> 1.0)
       dry-core (~> 0.5, >= 0.5.0)
@@ -508,6 +512,8 @@ DEPENDENCIES
   dalli
   debug_inspector
   delayed_job_active_record
+  doorkeeper
+  doorkeeper-i18n
   erb_lint
   factory_bot_rails
   faraday
index 9c832b43ac48bc278ba4b2f7e0dab3b99aaf7c11..c03ab288ca36d06c309c90e406a8ad5f18ea5398 100644 (file)
@@ -39,6 +39,9 @@ class Ability
 
       if Settings.status != "database_offline"
         can [:index, :new, :create, :show, :edit, :update, :destroy], ClientApplication
+        can [:index, :new, :create, :show, :edit, :update, :destroy], :oauth2_application
+        can [:index, :destroy], :oauth2_authorized_application
+        can [:new, :show, :create, :destroy], :oauth2_authorization
         can [:new, :create, :edit, :update, :comment, :subscribe, :unsubscribe], DiaryEntry
         can [:make_friend, :remove_friend], Friendship
         can [:new, :create, :reply, :show, :inbox, :outbox, :mark, :destroy], Message
index beb4d39bf3fa62ba20641899a89220cb91594b71..04d7fe10ab0953928a89c455b87761ae2c0ed173 100644 (file)
@@ -5,29 +5,35 @@ class ApiCapability
 
   def initialize(token)
     if Settings.status != "database_offline"
-      can [:create, :comment, :close, :reopen], Note if capability?(token, :allow_write_notes)
-      can [:show, :data], Trace if capability?(token, :allow_read_gpx)
-      can [:create, :update, :destroy], Trace if capability?(token, :allow_write_gpx)
-      can [:details], User if capability?(token, :allow_read_prefs)
-      can [:gpx_files], User if capability?(token, :allow_read_gpx)
-      can [:index, :show], UserPreference if capability?(token, :allow_read_prefs)
-      can [:update, :update_all, :destroy], UserPreference if capability?(token, :allow_write_prefs)
+      user = if token.respond_to?(:resource_owner_id)
+               User.find(token.resource_owner_id)
+             elsif token.respond_to?(:user)
+               token.user
+             end
 
-      if token&.user&.terms_agreed?
-        can [:create, :update, :upload, :close, :subscribe, :unsubscribe], Changeset if capability?(token, :allow_write_api)
-        can :create, ChangesetComment if capability?(token, :allow_write_api)
-        can [:create, :update, :delete], Node if capability?(token, :allow_write_api)
-        can [:create, :update, :delete], Way if capability?(token, :allow_write_api)
-        can [:create, :update, :delete], Relation if capability?(token, :allow_write_api)
+      can [:create, :comment, :close, :reopen], Note if scope?(token, :write_notes)
+      can [:show, :data], Trace if scope?(token, :read_gpx)
+      can [:create, :update, :destroy], Trace if scope?(token, :write_gpx)
+      can [:details], User if scope?(token, :read_prefs)
+      can [:gpx_files], User if scope?(token, :read_gpx)
+      can [:index, :show], UserPreference if scope?(token, :read_prefs)
+      can [:update, :update_all, :destroy], UserPreference if scope?(token, :write_prefs)
+
+      if user&.terms_agreed?
+        can [:create, :update, :upload, :close, :subscribe, :unsubscribe], Changeset if scope?(token, :write_api)
+        can :create, ChangesetComment if scope?(token, :write_api)
+        can [:create, :update, :delete], Node if scope?(token, :write_api)
+        can [:create, :update, :delete], Way if scope?(token, :write_api)
+        can [:create, :update, :delete], Relation if scope?(token, :write_api)
       end
 
-      if token&.user&.moderator?
-        can [:destroy, :restore], ChangesetComment if capability?(token, :allow_write_api)
-        can :destroy, Note if capability?(token, :allow_write_notes)
-        if token&.user&.terms_agreed?
-          can :redact, OldNode if capability?(token, :allow_write_api)
-          can :redact, OldWay if capability?(token, :allow_write_api)
-          can :redact, OldRelation if capability?(token, :allow_write_api)
+      if user&.moderator?
+        can [:destroy, :restore], ChangesetComment if scope?(token, :write_api)
+        can :destroy, Note if scope?(token, :write_notes)
+        if user&.terms_agreed?
+          can :redact, OldNode if scope?(token, :write_api)
+          can :redact, OldWay if scope?(token, :write_api)
+          can :redact, OldRelation if scope?(token, :write_api)
         end
       end
     end
@@ -35,7 +41,7 @@ class ApiCapability
 
   private
 
-  def capability?(token, cap)
-    token&.read_attribute(cap)
+  def scope?(token, scope)
+    token&.includes_scope?(scope)
   end
 end
index c905b24ce8c9b890facb97c9951b7f107813765a..a138976409bba94bb259fbc1caac61935aa1f325 100644 (file)
@@ -61,7 +61,9 @@ class ApiController < ApplicationController
 
   def current_ability
     # Use capabilities from the oauth token if it exists and is a valid access token
-    if Authenticator.new(self, [:token]).allow?
+    if doorkeeper_token&.accessible?
+      ApiAbility.new(nil).merge(ApiCapability.new(doorkeeper_token))
+    elsif Authenticator.new(self, [:token]).allow?
       ApiAbility.new(nil).merge(ApiCapability.new(current_token))
     else
       ApiAbility.new(current_user)
@@ -69,7 +71,7 @@ class ApiController < ApplicationController
   end
 
   def deny_access(_exception)
-    if current_token
+    if doorkeeper_token || current_token
       set_locale
       report_error t("oauth.permissions.missing"), :forbidden
     elsif current_user
@@ -94,7 +96,11 @@ class ApiController < ApplicationController
   # is optional.
   def setup_user_auth
     # try and setup using OAuth
-    unless Authenticator.new(self, [:token]).allow?
+    if doorkeeper_token&.accessible?
+      self.current_user = User.find(doorkeeper_token.resource_owner_id)
+    elsif Authenticator.new(self, [:token]).allow?
+      # self.current_user setup by OAuth
+    else
       username, passwd = get_auth_data # parse from headers
       # authenticate per-scheme
       self.current_user = if username.nil?
index b4c0fbc23feb7a5f1fb5c4f1482f740357fde78c..fc8b75b60144ec40e2bf2a851ae7fcdce8c171da 100644 (file)
@@ -345,7 +345,7 @@ class ApplicationController < ActionController::Base
   end
 
   def deny_access(_exception)
-    if current_token
+    if doorkeeper_token || current_token
       set_locale
       report_error t("oauth.permissions.missing"), :forbidden
     elsif current_user
diff --git a/app/controllers/oauth2_applications_controller.rb b/app/controllers/oauth2_applications_controller.rb
new file mode 100644 (file)
index 0000000..63b77be
--- /dev/null
@@ -0,0 +1,28 @@
+class Oauth2ApplicationsController < Doorkeeper::ApplicationsController
+  layout "site"
+
+  prepend_before_action :authorize_web
+  before_action :set_locale
+  before_action :set_application, :only => [:show, :edit, :update, :destroy]
+
+  authorize_resource :class => false
+
+  def index
+    @applications = current_resource_owner.oauth2_applications.ordered_by(:created_at)
+  end
+
+  private
+
+  def set_application
+    @application = current_resource_owner&.oauth2_applications&.find(params[:id])
+  rescue ActiveRecord::RecordNotFound
+    render :action => "not_found", :status => :not_found
+  end
+
+  def application_params
+    params[:doorkeeper_application][:scopes]&.delete("")
+    params.require(:doorkeeper_application)
+          .permit(:name, :redirect_uri, :confidential, :scopes => [])
+          .merge(:owner => current_resource_owner)
+  end
+end
diff --git a/app/controllers/oauth2_authorizations_controller.rb b/app/controllers/oauth2_authorizations_controller.rb
new file mode 100644 (file)
index 0000000..b851d19
--- /dev/null
@@ -0,0 +1,14 @@
+class Oauth2AuthorizationsController < Doorkeeper::AuthorizationsController
+  layout "site"
+
+  prepend_before_action :authorize_web
+  before_action :set_locale
+
+  authorize_resource :class => false
+
+  def new
+    override_content_security_policy_directives(:form_action => []) if Settings.csp_enforce || Settings.key?(:csp_report_url)
+
+    super
+  end
+end
diff --git a/app/controllers/oauth2_authorized_applications_controller.rb b/app/controllers/oauth2_authorized_applications_controller.rb
new file mode 100644 (file)
index 0000000..369908b
--- /dev/null
@@ -0,0 +1,8 @@
+class Oauth2AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicationsController
+  layout "site"
+
+  prepend_before_action :authorize_web
+  before_action :set_locale
+
+  authorize_resource :class => false
+end
index e5ba2e240916cce272059fa4abd316501ed785ac..1a5ff8553c825a785ee5fff21fa1aa42dcae618c 100644 (file)
@@ -45,6 +45,10 @@ class AccessToken < OauthToken
 
   before_create :set_authorized_at
 
+  def includes_scope?(scope)
+    self[:"allow_#{scope}"]
+  end
+
   protected
 
   def set_authorized_at
index ee39c294b934bab921dc919bcf9b202037acb67f..1b2faafbb1d79c96b63da5303bf20f0ba8ae785b 100644 (file)
@@ -62,7 +62,7 @@ class ClientApplication < ApplicationRecord
   end
 
   def self.all_permissions
-    PERMISSIONS
+    Oauth.scopes.collect { |s| :"allow_#{s.name}" }
   end
 
   def oauth_server
@@ -102,11 +102,6 @@ class ClientApplication < ApplicationRecord
 
   protected
 
-  # this is the set of permissions that the client can ask for. clients
-  # have to say up-front what permissions they want and when users sign up they
-  # can agree or not agree to each of them.
-  PERMISSIONS = [:allow_read_prefs, :allow_write_prefs, :allow_write_diary, :allow_write_api, :allow_read_gpx, :allow_write_gpx, :allow_write_notes].freeze
-
   def generate_keys
     self.key = OAuth::Helper.generate_key(40)[0, 40]
     self.secret = OAuth::Helper.generate_key(40)[0, 40]
index 3dbaa688c966643a92707cd2ae7cf48586713068..964359e9cd4cec972f596f08eec1b45c71de3fe2 100644 (file)
@@ -68,6 +68,10 @@ class User < ApplicationRecord
   has_many :client_applications
   has_many :oauth_tokens, -> { order(:authorized_at => :desc).preload(:client_application) }, :class_name => "OauthToken"
 
+  has_many :oauth2_applications, :class_name => Doorkeeper.config.application_model.name, :foreign_key => :owner_id
+  has_many :access_grants, :class_name => Doorkeeper.config.access_grant_model.name, :foreign_key => :resource_owner_id
+  has_many :access_tokens, :class_name => Doorkeeper.config.access_token_model.name, :foreign_key => :resource_owner_id
+
   has_many :blocks, :class_name => "UserBlock"
   has_many :blocks_created, :class_name => "UserBlock", :foreign_key => :creator_id
   has_many :blocks_revoked, :class_name => "UserBlock", :foreign_key => :revoker_id
diff --git a/app/views/oauth2_applications/_application.html.erb b/app/views/oauth2_applications/_application.html.erb
new file mode 100644 (file)
index 0000000..564fa81
--- /dev/null
@@ -0,0 +1,23 @@
+<tr>
+  <td class="align-middle">
+    <ul class="list-unstyled mb-0">
+      <li><%= link_to application.name, oauth_application_path(application) %></li>
+      <% application.redirect_uri.split.each do |uri| -%>
+        <li class="text-muted"><%= uri %></li>
+      <% end -%>
+    </ul>
+  </td>
+  <td class="align-middle">
+    <ul class="list-unstyled mb-0">
+      <% application.scopes.each do |scope| -%>
+        <li><%= t "oauth.scopes.#{scope}" %> <code class="text-muted">(<%= scope %>)</code></li>
+      <% end -%>
+    </ul>
+  </td>
+  <td class="align-middle">
+    <%= link_to t(".edit"), edit_oauth_application_path(application), :class => "btn btn-outline-primary" %>
+  </td>
+  <td class="align-middle">
+    <%= link_to t(".delete"), oauth_application_path(application), { :method => :delete, :class => "btn btn-outline-danger", :data => { :confirm => t(".confirm_delete") } } %>
+  </td>
+</tr>
diff --git a/app/views/oauth2_applications/_form.html.erb b/app/views/oauth2_applications/_form.html.erb
new file mode 100644 (file)
index 0000000..d69536c
--- /dev/null
@@ -0,0 +1,7 @@
+<%= f.text_field :name %>
+<%= f.text_area :redirect_uri %>
+<%= f.form_group :confidential do %>
+  <%= f.check_box :confidential %>
+<% end %>
+<%= f.collection_check_boxes :scopes, Oauth.scopes, :name, :description %>
+<%= f.primary %>
diff --git a/app/views/oauth2_applications/edit.html.erb b/app/views/oauth2_applications/edit.html.erb
new file mode 100644 (file)
index 0000000..94b5189
--- /dev/null
@@ -0,0 +1,7 @@
+<% content_for :heading do %>
+  <h1><%= t ".title" %></h1>
+<% end %>
+
+<%= bootstrap_form_for @application, :url => oauth_application_path(@application), :html => { :method => :put } do |f| %>
+  <%= render :partial => "form", :locals => { :f => f } %>
+<% end %>
diff --git a/app/views/oauth2_applications/index.html.erb b/app/views/oauth2_applications/index.html.erb
new file mode 100644 (file)
index 0000000..240b34d
--- /dev/null
@@ -0,0 +1,23 @@
+<% content_for :heading do %>
+  <h1><%= t ".title" %></h1>
+<% end %>
+
+<% if @applications.length > 0 %>
+  <table class="table table-borderless table-striped">
+    <thead>
+      <th><%= t ".name" %></th>
+      <th><%= t ".permissions" %></th>
+      <th></th>
+      <th></th>
+    </thead>
+    <tbody>
+      <%= render :partial => "application", :collection => @applications %>
+    </tbody>
+  </table>
+<% else %>
+  <p><%= t ".no_applications_html", :oauth2 => link_to(t(".oauth_2"), "https://oauth.net/2/") %></p>
+<% end %>
+
+<p>
+  <%= link_to t(".new"), new_oauth_application_path, :class => "btn btn-outline-primary" %>
+</p>
diff --git a/app/views/oauth2_applications/new.html.erb b/app/views/oauth2_applications/new.html.erb
new file mode 100644 (file)
index 0000000..a9d6f4a
--- /dev/null
@@ -0,0 +1,7 @@
+<% content_for :heading do %>
+  <h1><%= t ".title" %></h1>
+<% end %>
+
+<%= bootstrap_form_for @application, :url => { :action => :create } do |f| %>
+  <%= render :partial => "form", :locals => { :f => f } %>
+<% end %>
diff --git a/app/views/oauth2_applications/not_found.html.erb b/app/views/oauth2_applications/not_found.html.erb
new file mode 100644 (file)
index 0000000..641b1df
--- /dev/null
@@ -0,0 +1 @@
+<p><%= t ".sorry" %></p>
diff --git a/app/views/oauth2_applications/show.html.erb b/app/views/oauth2_applications/show.html.erb
new file mode 100644 (file)
index 0000000..e8ab783
--- /dev/null
@@ -0,0 +1,49 @@
+<% content_for :heading do %>
+  <h1><%= @application.name %></h1>
+<% end %>
+
+<% secret = flash[:application_secret].presence || @application.plaintext_secret %>
+
+<table class="table table-borderless">
+  <tr>
+    <th><%= t ".client_id" %></th>
+    <td><code><%= @application.uid %></code></td>
+  </tr>
+  <% unless secret.blank? && Doorkeeper.config.application_secret_hashed? -%>
+  <tr>
+    <th><%= t ".client_secret" %></th>
+    <td>
+      <code><%= secret %></code>
+      <% if Doorkeeper.config.application_secret_hashed? -%>
+      <br />
+      <small class="text-danger"><%= t ".client_secret_warning" %></small>
+      <% end -%>
+    </td>
+  </tr>
+  <% end -%>
+  <tr>
+    <th><%= t ".permissions" %></th>
+    <td>
+      <ul class="list-unstyled mb-0">
+        <% @application.scopes.each do |scope| -%>
+          <li><%= t "oauth.scopes.#{scope}" %> <code class="text-muted">(<%= scope %>)</code></li>
+        <% end -%>
+      </ul>
+    </td>
+  </tr>
+  <tr>
+    <th><%= t ".redirect_uris" %></th>
+    <td>
+      <ul class="list-unstyled mb-0">
+        <% @application.redirect_uri.split.each do |uri| -%>
+          <li><%= uri %></li>
+        <% end -%>
+      </ul>
+    </td>
+  </tr>
+</table>
+
+<div>
+  <%= link_to t(".edit"), edit_oauth_application_path(@application), :class => "btn btn-outline-primary" %>
+  <%= link_to t(".delete"), oauth_application_path(@application), { :method => :delete, :class => "btn btn-outline-danger", :data => { :confirm => t(".confirm_delete") } } %>
+</td>
diff --git a/app/views/oauth2_authorizations/error.html.erb b/app/views/oauth2_authorizations/error.html.erb
new file mode 100644 (file)
index 0000000..7df81da
--- /dev/null
@@ -0,0 +1,5 @@
+<% content_for :heading do %>
+  <h1><%= t ".title" %></h1>
+<% end %>
+
+<p><%= @pre_auth.error_response.body[:error_description] %></p>
diff --git a/app/views/oauth2_authorizations/new.html.erb b/app/views/oauth2_authorizations/new.html.erb
new file mode 100644 (file)
index 0000000..3b943a0
--- /dev/null
@@ -0,0 +1,38 @@
+<% content_for :heading do %>
+  <h1><%= t ".title" %></h1>
+<% end %>
+
+<p><%= t ".introduction", :application => @pre_auth.client.name %></p>
+
+<ul>
+  <% @pre_auth.scopes.each do |scope| -%>
+    <li><%= t "oauth.scopes.#{scope}" %></li>
+  <% end -%>
+</ul>
+
+<div class="row justify-content-start no-gutters mx-n1">
+  <div class="col-auto mx-1">
+    <%= bootstrap_form_tag :action => :create do |f| %>
+      <%= f.hidden_field :client_id, :value => @pre_auth.client.uid %>
+      <%= f.hidden_field :redirect_uri, :value => @pre_auth.redirect_uri %>
+      <%= f.hidden_field :state, :value => @pre_auth.state %>
+      <%= f.hidden_field :response_type, :value => @pre_auth.response_type %>
+      <%= f.hidden_field :scope, :value => @pre_auth.scope %>
+      <%= f.hidden_field :code_challenge, :value => @pre_auth.code_challenge %>
+      <%= f.hidden_field :code_challenge_method, :value => @pre_auth.code_challenge_method %>
+      <%= f.primary t(".authorize") %>
+    <% end %>
+  </div>
+  <div class="col-auto mx-1">
+    <%= bootstrap_form_tag :action => :destroy, :html => { :method => :delete } do |f| %>
+      <%= f.hidden_field :client_id, :value => @pre_auth.client.uid %>
+      <%= f.hidden_field :redirect_uri, :value => @pre_auth.redirect_uri %>
+      <%= f.hidden_field :state, :value => @pre_auth.state %>
+      <%= f.hidden_field :response_type, :value => @pre_auth.response_type %>
+      <%= f.hidden_field :scope, :value => @pre_auth.scope %>
+      <%= f.hidden_field :code_challenge, :value => @pre_auth.code_challenge %>
+      <%= f.hidden_field :code_challenge_method, :value => @pre_auth.code_challenge_method %>
+      <%= f.submit t(".deny") %>
+    <% end %>
+  </div>
+</div>
diff --git a/app/views/oauth2_authorizations/show.html.erb b/app/views/oauth2_authorizations/show.html.erb
new file mode 100644 (file)
index 0000000..b55757b
--- /dev/null
@@ -0,0 +1,5 @@
+<% content_for :heading do %>
+  <h1><%= t ".title" %></h1>
+<% end %>
+
+<code id="authorization_code"><%= params[:code] %></code>
diff --git a/app/views/oauth2_authorized_applications/_application.html.erb b/app/views/oauth2_authorized_applications/_application.html.erb
new file mode 100644 (file)
index 0000000..3781c1d
--- /dev/null
@@ -0,0 +1,15 @@
+<tr>
+  <td class="align-middle">
+    <%= link_to application.name, oauth_application_path(application) %>
+  </td>
+  <td class="align-middle">
+    <ul class="list-unstyled mb-0">
+      <% application.scopes.each do |scope| -%>
+        <li><%= t "oauth.scopes.#{scope}" %></li>
+      <% end -%>
+    </ul>
+  </td>
+  <td class="align-middle text-right">
+    <%= link_to t(".revoke"), oauth_authorized_application_path(application), { :method => :delete, :class => "btn btn-outline-danger", :data => { :confirm => t(".confirm_revoke") } } %>
+  </td>
+</tr>
diff --git a/app/views/oauth2_authorized_applications/index.html.erb b/app/views/oauth2_authorized_applications/index.html.erb
new file mode 100644 (file)
index 0000000..336de14
--- /dev/null
@@ -0,0 +1,18 @@
+<% content_for :heading do %>
+  <h1><%= t ".title" %></h1>
+<% end %>
+
+<% if @applications.length > 0 %>
+  <table class="table table-borderless table-striped">
+    <thead>
+      <th><%= t ".application" %></th>
+      <th><%= t ".permissions" %></th>
+      <th></th>
+    </thead>
+    <tbody>
+      <%= render :partial => "application", :collection => @applications %>
+    </tbody>
+  </table>
+<% else %>
+  <p><%= t ".no_applications_html", :oauth2 => link_to(t(".oauth_2"), "https://oauth.net/2/") %></p>
+<% end %>
index 10b33063e773c4c04611abb58da9e96e2b3148e2..dc31de97aea3d6671b55219c8ed8ab96225b54b1 100644 (file)
@@ -6,7 +6,9 @@
   <h1><%= t ".my settings" %></h1>
   <ul class='secondary-actions clearfix'>
     <li><%= link_to t(".return to profile"), user_path(current_user) %></li>
-    <li><%= link_to t("users.show.oauth settings"), :controller => "oauth_clients", :action => "index" %></li>
+    <li><%= link_to t(".oauth1 settings"), oauth_clients_path %></li>
+    <li><%= link_to t(".oauth2 applications"), oauth_applications_path %></li>
+    <li><%= link_to t(".oauth2 authorizations"), oauth_authorized_applications_path %></li>
   </ul>
 <% end %>
 
diff --git a/config/initializers/doorkeeper.rb b/config/initializers/doorkeeper.rb
new file mode 100644 (file)
index 0000000..532e972
--- /dev/null
@@ -0,0 +1,461 @@
+# frozen_string_literal: true
+
+Doorkeeper.configure do
+  # Change the ORM that doorkeeper will use (requires ORM extensions installed).
+  # Check the list of supported ORMs here: https://github.com/doorkeeper-gem/doorkeeper#orms
+  orm :active_record
+
+  # This block will be called to check whether the resource owner is authenticated or not.
+  resource_owner_authenticator do
+    current_user
+  end
+
+  # If you didn't skip applications controller from Doorkeeper routes in your application routes.rb
+  # file then you need to declare this block in order to restrict access to the web interface for
+  # adding oauth authorized applications. In other case it will return 403 Forbidden response
+  # every time somebody will try to access the admin web interface.
+
+  admin_authenticator do
+    current_user
+  end
+
+  # You can use your own model classes if you need to extend (or even override) default
+  # Doorkeeper models such as `Application`, `AccessToken` and `AccessGrant.
+  #
+  # Be default Doorkeeper ActiveRecord ORM uses it's own classes:
+  #
+  # access_token_class "Doorkeeper::AccessToken"
+  # access_grant_class "Doorkeeper::AccessGrant"
+  # application_class "Doorkeeper::Application"
+  #
+  # Don't forget to include Doorkeeper ORM mixins into your custom models:
+  #
+  #   *  ::Doorkeeper::Orm::ActiveRecord::Mixins::AccessToken - for access token
+  #   *  ::Doorkeeper::Orm::ActiveRecord::Mixins::AccessGrant - for access grant
+  #   *  ::Doorkeeper::Orm::ActiveRecord::Mixins::Application - for application (OAuth2 clients)
+  #
+  # For example:
+  #
+  # access_token_class "MyAccessToken"
+  #
+  # class MyAccessToken < ApplicationRecord
+  #   include ::Doorkeeper::Orm::ActiveRecord::Mixins::AccessToken
+  #
+  #   self.table_name = "hey_i_wanna_my_name"
+  #
+  #   def destroy_me!
+  #     destroy
+  #   end
+  # end
+
+  # Enables polymorphic Resource Owner association for Access Tokens and Access Grants.
+  # By default this option is disabled.
+  #
+  # Make sure you properly setup you database and have all the required columns (run
+  # `bundle exec rails generate doorkeeper:enable_polymorphic_resource_owner` and execute Rails
+  # migrations).
+  #
+  # If this option enabled, Doorkeeper will store not only Resource Owner primary key
+  # value, but also it's type (class name). See "Polymorphic Associations" section of
+  # Rails guides: https://guides.rubyonrails.org/association_basics.html#polymorphic-associations
+  #
+  # [NOTE] If you apply this option on already existing project don't forget to manually
+  # update `resource_owner_type` column in the database and fix migration template as it will
+  # set NOT NULL constraint for Access Grants table.
+  #
+  # use_polymorphic_resource_owner
+
+  # If you are planning to use Doorkeeper in Rails 5 API-only application, then you might
+  # want to use API mode that will skip all the views management and change the way how
+  # Doorkeeper responds to a requests.
+  #
+  # api_only
+
+  # Enforce token request content type to application/x-www-form-urlencoded.
+  # It is not enabled by default to not break prior versions of the gem.
+
+  enforce_content_type
+
+  # Authorization Code expiration time (default: 10 minutes).
+  #
+  # authorization_code_expires_in 10.minutes
+
+  # Access token expiration time (default: 2 hours).
+  # If you want to disable expiration, set this to `nil`.
+
+  access_token_expires_in nil
+
+  # Assign custom TTL for access tokens. Will be used instead of access_token_expires_in
+  # option if defined. In case the block returns `nil` value Doorkeeper fallbacks to
+  # +access_token_expires_in+ configuration option value. If you really need to issue a
+  # non-expiring access token (which is not recommended) then you need to return
+  # Float::INFINITY from this block.
+  #
+  # `context` has the following properties available:
+  #
+  # `client` - the OAuth client application (see Doorkeeper::OAuth::Client)
+  # `grant_type` - the grant type of the request (see Doorkeeper::OAuth)
+  # `scopes` - the requested scopes (see Doorkeeper::OAuth::Scopes)
+  #
+  # custom_access_token_expires_in do |context|
+  #   context.client.application.additional_settings.implicit_oauth_expiration
+  # end
+
+  # Use a custom class for generating the access token.
+  # See https://doorkeeper.gitbook.io/guides/configuration/other-configurations#custom-access-token-generator
+  #
+  # access_token_generator '::Doorkeeper::JWT'
+
+  # The controller +Doorkeeper::ApplicationController+ inherits from.
+  # Defaults to +ActionController::Base+ unless +api_only+ is set, which changes the default to
+  # +ActionController::API+. The return value of this option must be a stringified class name.
+  # See https://doorkeeper.gitbook.io/guides/configuration/other-configurations#custom-base-controller
+
+  base_controller "ApplicationController"
+
+  # Reuse access token for the same resource owner within an application (disabled by default).
+  #
+  # This option protects your application from creating new tokens before old valid one becomes
+  # expired so your database doesn't bloat. Keep in mind that when this option is `on` Doorkeeper
+  # doesn't updates existing token expiration time, it will create a new token instead.
+  # Rationale: https://github.com/doorkeeper-gem/doorkeeper/issues/383
+  #
+  # You can not enable this option together with +hash_token_secrets+.
+  #
+  # reuse_access_token
+
+  # In case you enabled `reuse_access_token` option Doorkeeper will try to find matching
+  # token using `matching_token_for` Access Token API that searches for valid records
+  # in batches in order not to pollute the memory with all the database records. By default
+  # Doorkeeper uses batch size of 10 000 records. You can increase or decrease this value
+  # depending on your needs and server capabilities.
+  #
+  # token_lookup_batch_size 10_000
+
+  # Set a limit for token_reuse if using reuse_access_token option
+  #
+  # This option limits token_reusability to some extent.
+  # If not set then access_token will be reused unless it expires.
+  # Rationale: https://github.com/doorkeeper-gem/doorkeeper/issues/1189
+  #
+  # This option should be a percentage(i.e. (0,100])
+  #
+  # token_reuse_limit 100
+
+  # Only allow one valid access token obtained via client credentials
+  # per client. If a new access token is obtained before the old one
+  # expired, the old one gets revoked (disabled by default)
+  #
+  # When enabling this option, make sure that you do not expect multiple processes
+  # using the same credentials at the same time (e.g. web servers spanning
+  # multiple machines and/or processes).
+  #
+  # revoke_previous_client_credentials_token
+
+  # Hash access and refresh tokens before persisting them.
+  # This will disable the possibility to use +reuse_access_token+
+  # since plain values can no longer be retrieved.
+  #
+  # Note: If you are already a user of doorkeeper and have existing tokens
+  # in your installation, they will be invalid without enabling the additional
+  # setting `fallback_to_plain_secrets` below.
+
+  hash_token_secrets
+
+  # Hash application secrets before persisting them.
+
+  hash_application_secrets
+
+  # When the above option is enabled, and a hashed token or secret is not found,
+  # you can allow to fall back to another strategy. For users upgrading
+  # doorkeeper and wishing to enable hashing, you will probably want to enable
+  # the fallback to plain tokens.
+  #
+  # This will ensure that old access tokens and secrets
+  # will remain valid even if the hashing above is enabled.
+  #
+  # fallback_to_plain_secrets
+
+  # Issue access tokens with refresh token (disabled by default), you may also
+  # pass a block which accepts `context` to customize when to give a refresh
+  # token or not. Similar to +custom_access_token_expires_in+, `context` has
+  # the following properties:
+  #
+  # `client` - the OAuth client application (see Doorkeeper::OAuth::Client)
+  # `grant_type` - the grant type of the request (see Doorkeeper::OAuth)
+  # `scopes` - the requested scopes (see Doorkeeper::OAuth::Scopes)
+  #
+  # use_refresh_token
+
+  # Provide support for an owner to be assigned to each registered application (disabled by default)
+  # Optional parameter confirmation: true (default: false) if you want to enforce ownership of
+  # a registered application
+  # NOTE: you must also run the rails g doorkeeper:application_owner generator
+  # to provide the necessary support
+
+  enable_application_owner :confirmation => true
+
+  # Define access token scopes for your provider
+  # For more information go to
+  # https://doorkeeper.gitbook.io/guides/ruby-on-rails/scopes
+
+  # default_scopes  :public
+  optional_scopes(*Oauth::SCOPES)
+
+  # Allows to restrict only certain scopes for grant_type.
+  # By default, all the scopes will be available for all the grant types.
+  #
+  # Keys to this hash should be the name of grant_type and
+  # values should be the array of scopes for that grant type.
+  # Note: scopes should be from configured_scopes (i.e. default or optional)
+  #
+  # scopes_by_grant_type password: [:write], client_credentials: [:update]
+
+  # Forbids creating/updating applications with arbitrary scopes that are
+  # not in configuration, i.e. +default_scopes+ or +optional_scopes+.
+  # (disabled by default)
+
+  enforce_configured_scopes
+
+  # Change the way client credentials are retrieved from the request object.
+  # By default it retrieves first from the `HTTP_AUTHORIZATION` header, then
+  # falls back to the `:client_id` and `:client_secret` params from the `params` object.
+  # Check out https://github.com/doorkeeper-gem/doorkeeper/wiki/Changing-how-clients-are-authenticated
+  # for more information on customization
+  #
+  # client_credentials :from_basic, :from_params
+
+  # Change the way access token is authenticated from the request object.
+  # By default it retrieves first from the `HTTP_AUTHORIZATION` header, then
+  # falls back to the `:access_token` or `:bearer_token` params from the `params` object.
+  # Check out https://github.com/doorkeeper-gem/doorkeeper/wiki/Changing-how-clients-are-authenticated
+  # for more information on customization
+
+  access_token_methods :from_bearer_authorization
+
+  # Forces the usage of the HTTPS protocol in non-native redirect uris (enabled
+  # by default in non-development environments). OAuth2 delegates security in
+  # communication to the HTTPS protocol so it is wise to keep this enabled.
+  #
+  # Callable objects such as proc, lambda, block or any object that responds to
+  # #call can be used in order to allow conditional checks (to allow non-SSL
+  # redirects to localhost for example).
+
+  force_ssl_in_redirect_uri do |uri|
+    !Rails.env.development? && uri.host != "127.0.0.1"
+  end
+
+  # Specify what redirect URI's you want to block during Application creation.
+  # Any redirect URI is whitelisted by default.
+  #
+  # You can use this option in order to forbid URI's with 'javascript' scheme
+  # for example.
+  #
+  # forbid_redirect_uri { |uri| uri.scheme.to_s.downcase == 'javascript' }
+
+  # Allows to set blank redirect URIs for Applications in case Doorkeeper configured
+  # to use URI-less OAuth grant flows like Client Credentials or Resource Owner
+  # Password Credentials. The option is on by default and checks configured grant
+  # types, but you **need** to manually drop `NOT NULL` constraint from `redirect_uri`
+  # column for `oauth_applications` database table.
+  #
+  # You can completely disable this feature with:
+  #
+  # allow_blank_redirect_uri false
+  #
+  # Or you can define your custom check:
+  #
+  # allow_blank_redirect_uri do |grant_flows, client|
+  #   client.superapp?
+  # end
+
+  # Specify how authorization errors should be handled.
+  # By default, doorkeeper renders json errors when access token
+  # is invalid, expired, revoked or has invalid scopes.
+  #
+  # If you want to render error response yourself (i.e. rescue exceptions),
+  # set +handle_auth_errors+ to `:raise` and rescue Doorkeeper::Errors::InvalidToken
+  # or following specific errors:
+  #
+  #   Doorkeeper::Errors::TokenForbidden, Doorkeeper::Errors::TokenExpired,
+  #   Doorkeeper::Errors::TokenRevoked, Doorkeeper::Errors::TokenUnknown
+  #
+  # handle_auth_errors :raise
+
+  # Customize token introspection response.
+  # Allows to add your own fields to default one that are required by the OAuth spec
+  # for the introspection response. It could be `sub`, `aud` and so on.
+  # This configuration option can be a proc, lambda or any Ruby object responds
+  # to `.call` method and result of it's invocation must be a Hash.
+  #
+  # custom_introspection_response do |token, context|
+  #   {
+  #     "sub": "Z5O3upPC88QrAjx00dis",
+  #     "aud": "https://protected.example.net/resource",
+  #     "username": User.find(token.resource_owner_id).username
+  #   }
+  # end
+  #
+  # or
+  #
+  # custom_introspection_response CustomIntrospectionResponder
+
+  # Specify what grant flows are enabled in array of Strings. The valid
+  # strings and the flows they enable are:
+  #
+  # "authorization_code" => Authorization Code Grant Flow
+  # "implicit"           => Implicit Grant Flow
+  # "password"           => Resource Owner Password Credentials Grant Flow
+  # "client_credentials" => Client Credentials Grant Flow
+  #
+  # If not specified, Doorkeeper enables authorization_code and
+  # client_credentials.
+  #
+  # implicit and password grant flows have risks that you should understand
+  # before enabling:
+  #   http://tools.ietf.org/html/rfc6819#section-4.4.2
+  #   http://tools.ietf.org/html/rfc6819#section-4.4.3
+
+  grant_flows %w[authorization_code]
+
+  # Allows to customize OAuth grant flows that +each+ application support.
+  # You can configure a custom block (or use a class respond to `#call`) that must
+  # return `true` in case Application instance supports requested OAuth grant flow
+  # during the authorization request to the server. This configuration +doesn't+
+  # set flows per application, it only allows to check if application supports
+  # specific grant flow.
+  #
+  # For example you can add an additional database column to `oauth_applications` table,
+  # say `t.array :grant_flows, default: []`, and store allowed grant flows that can
+  # be used with this application there. Then when authorization requested Doorkeeper
+  # will call this block to check if specific Application (passed with client_id and/or
+  # client_secret) is allowed to perform the request for the specific grant type
+  # (authorization, password, client_credentials, etc).
+  #
+  # Example of the block:
+  #
+  #   ->(flow, client) { client.grant_flows.include?(flow) }
+  #
+  # In case this option invocation result is `false`, Doorkeeper server returns
+  # :unauthorized_client error and stops the request.
+  #
+  # @param allow_grant_flow_for_client [Proc] Block or any object respond to #call
+  # @return [Boolean] `true` if allow or `false` if forbid the request
+  #
+  # allow_grant_flow_for_client do |grant_flow, client|
+  #   # `grant_flows` is an Array column with grant
+  #   # flows that application supports
+  #
+  #   client.grant_flows.include?(grant_flow)
+  # end
+
+  # If you need arbitrary Resource Owner-Client authorization you can enable this option
+  # and implement the check your need. Config option must respond to #call and return
+  # true in case resource owner authorized for the specific application or false in other
+  # cases.
+  #
+  # Be default all Resource Owners are authorized to any Client (application).
+  #
+  # authorize_resource_owner_for_client do |client, resource_owner|
+  #   resource_owner.admin? || client.owners_whitelist.include?(resource_owner)
+  # end
+
+  # Hook into the strategies' request & response life-cycle in case your
+  # application needs advanced customization or logging:
+  #
+  # before_successful_strategy_response do |request|
+  #   puts "BEFORE HOOK FIRED! #{request}"
+  # end
+  #
+  # after_successful_strategy_response do |request, response|
+  #   puts "AFTER HOOK FIRED! #{request}, #{response}"
+  # end
+
+  # Hook into Authorization flow in order to implement Single Sign Out
+  # or add any other functionality. Inside the block you have an access
+  # to `controller` (authorizations controller instance) and `context`
+  # (Doorkeeper::OAuth::Hooks::Context instance) which provides pre auth
+  # or auth objects with issued token based on hook type (before or after).
+  #
+  # before_successful_authorization do |controller, context|
+  #   Rails.logger.info(controller.request.params.inspect)
+  #
+  #   Rails.logger.info(context.pre_auth.inspect)
+  # end
+  #
+  # after_successful_authorization do |controller, context|
+  #   controller.session[:logout_urls] <<
+  #     Doorkeeper::Application
+  #       .find_by(controller.request.params.slice(:redirect_uri))
+  #       .logout_uri
+  #
+  #   Rails.logger.info(context.auth.inspect)
+  #   Rails.logger.info(context.issued_token)
+  # end
+
+  # Under some circumstances you might want to have applications auto-approved,
+  # so that the user skips the authorization step.
+  # For example if dealing with a trusted application.
+  #
+  # skip_authorization do |resource_owner, client|
+  #   client.superapp? or resource_owner.admin?
+  # end
+
+  # Configure custom constraints for the Token Introspection request.
+  # By default this configuration option allows to introspect a token by another
+  # token of the same application, OR to introspect the token that belongs to
+  # authorized client (from authenticated client) OR when token doesn't
+  # belong to any client (public token). Otherwise requester has no access to the
+  # introspection and it will return response as stated in the RFC.
+  #
+  # Block arguments:
+  #
+  # @param token [Doorkeeper::AccessToken]
+  #   token to be introspected
+  #
+  # @param authorized_client [Doorkeeper::Application]
+  #   authorized client (if request is authorized using Basic auth with
+  #   Client Credentials for example)
+  #
+  # @param authorized_token [Doorkeeper::AccessToken]
+  #   Bearer token used to authorize the request
+  #
+  # In case the block returns `nil` or `false` introspection responses with 401 status code
+  # when using authorized token to introspect, or you'll get 200 with { "active": false } body
+  # when using authorized client to introspect as stated in the
+  # RFC 7662 section 2.2. Introspection Response.
+  #
+  # Using with caution:
+  # Keep in mind that these three parameters pass to block can be nil as following case:
+  #  `authorized_client` is nil if and only if `authorized_token` is present, and vice versa.
+  #  `token` will be nil if and only if `authorized_token` is present.
+  # So remember to use `&` or check if it is present before calling method on
+  # them to make sure you doesn't get NoMethodError exception.
+  #
+  # You can define your custom check:
+  #
+  # allow_token_introspection do |token, authorized_client, authorized_token|
+  #   if authorized_token
+  #     # customize: require `introspection` scope
+  #     authorized_token.application == token&.application ||
+  #       authorized_token.scopes.include?("introspection")
+  #   elsif token.application
+  #     # `protected_resource` is a new database boolean column, for example
+  #     authorized_client == token.application || authorized_client.protected_resource?
+  #   else
+  #     # public token (when token.application is nil, token doesn't belong to any application)
+  #     true
+  #   end
+  # end
+  #
+  # Or you can completely disable any token introspection:
+  #
+  # allow_token_introspection false
+  #
+  # If you need to block the request at all, then configure your routes.rb or web-server
+  # like nginx to forbid the request.
+
+  # WWW-Authenticate Realm (default: "Doorkeeper").
+  #
+  # realm "Doorkeeper"
+end
index 094a6e9c1c40687d90d313d776ae2d9303362903..6ebeb3746bd11eff8b1ee3290cdc84f225fe3749 100644 (file)
@@ -21,6 +21,9 @@ en:
       client_application:
         create: Register
         update: Update
+      doorkeeper_application:
+        create: Register
+        update: Update
       redaction:
         create: Create redaction
         update: Save redaction
@@ -95,6 +98,11 @@ en:
         latitude: "Latitude"
         longitude: "Longitude"
         language: "Language"
+      doorkeeper/application:
+        name: Name
+        redirect_uri: Redirect URIs
+        confidential: Confidential application?
+        scopes: Permissions
       friend:
         user: "User"
         friend: "Friend"
@@ -138,6 +146,9 @@ en:
         pass_crypt: "Password"
         pass_crypt_confirmation: "Confirm Password"
     help:
+      doorkeeper/application:
+        confidential: "Application will be used where the client secret can be kept confidential (native mobile apps and single page apps are not confidential)"
+        redirect_uri: "Use one line per URI"
       trace:
         tagstring: comma delimited
       user_block:
@@ -480,6 +491,11 @@ en:
       comment: Comment
       newer_comments: "Newer Comments"
       older_comments: "Older Comments"
+  doorkeeper:
+    flash:
+      applications:
+        create:
+          notice: Application Registered.
   friendships:
     make_friend:
       heading: "Add %{user} as a friend?"
@@ -2275,6 +2291,14 @@ en:
       flash: "You've revoked the token for %{application}"
     permissions:
       missing: "You have not permitted the application access to this facility"
+    scopes:
+      read_prefs: Read user preferences
+      write_prefs: Modify user preferences
+      write_diary: Create diary entries, comments and make friends
+      write_api: Modify the map
+      read_gpx: Read private GPS traces
+      write_gpx: Upload GPS traces
+      write_notes: Modify notes
   oauth_clients:
     new:
       title: "Register a new application"
@@ -2314,6 +2338,52 @@ en:
       flash: "Updated the client information successfully"
     destroy:
       flash: "Destroyed the client application registration"
+  oauth2_applications:
+    index:
+      title: "My client applications"
+      no_applications_html: "Do you have an application you would like to register for use with us using the %{oauth2} standard? You must register your application before it can make OAuth requests to this service."
+      oauth_2: "OAuth 2"
+      new: "Register new application"
+      name: "Name"
+      permissions: "Permissions"
+    application:
+      edit: "Edit"
+      delete: "Delete"
+      confirm_delete: "Delete this application?"
+    new:
+      title: "Register a new application"
+    edit:
+      title: "Edit your application"
+    show:
+      edit: "Edit"
+      delete: "Delete"
+      confirm_delete: "Delete this application?"
+      client_id: "Client ID"
+      client_secret: "Client Secret"
+      client_secret_warning: "Make sure to save this secret - it will not be accessible again"
+      permissions: "Permissions"
+      redirect_uris: "Redirect URIs"
+    not_found:
+      sorry: "Sorry, that application could not be found."
+  oauth2_authorizations:
+    new:
+      title: "Authorization required"
+      introduction: "Authorize %{application} to access your account with the following permissions?"
+      authorize: "Authorize"
+      deny: "Deny"
+    error:
+      title: "An error has occurred"
+    show:
+      title: "Authorization code"
+  oauth2_authorized_applications:
+    index:
+      title: "My authorized applications"
+      application: "Application"
+      permissions: "Permissions"
+      no_applications_html: "You have not yet authorized any %{oauth2} applications."
+    application:
+      revoke: "Revoke Access"
+      confirm_revoke: "Revoke access for this application?"
   users:
     new:
       title: "Sign Up"
@@ -2375,7 +2445,6 @@ en:
       my profile: My Profile
       my settings: My Settings
       my comments: My Comments
-      oauth settings: oauth settings
       blocks on me: Blocks on Me
       blocks by me: Blocks by Me
       send message: Send Message
@@ -2477,6 +2546,9 @@ en:
       save changes button: Save Changes
       make edits public button: Make all my edits public
       return to profile: Return to profile
+      oauth1 settings: OAuth 1 settings
+      oauth2 applications: OAuth 2 applications
+      oauth2 authorizations: OAuth 2 authorizations
       flash update success confirm needed: "User information updated successfully. Check your email for a note to confirm your new email address."
       flash update success: "User information updated successfully."
     set_home:
index da3921e4afe0b5921186d2da583411be5d34fda5..048db8b3365aad3335f2d147ac67754f5e1d15fa 100644 (file)
@@ -1,4 +1,10 @@
 OpenStreetMap::Application.routes.draw do
+  use_doorkeeper :scope => "oauth2" do
+    controllers :authorizations => "oauth2_authorizations",
+                :applications => "oauth2_applications",
+                :authorized_applications => "oauth2_authorized_applications"
+  end
+
   # API
   namespace :api do
     get "capabilities" => "capabilities#show" # Deprecated, remove when 0.6 support is removed
diff --git a/db/migrate/20201004105659_create_doorkeeper_tables.rb b/db/migrate/20201004105659_create_doorkeeper_tables.rb
new file mode 100644 (file)
index 0000000..66456bc
--- /dev/null
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+class CreateDoorkeeperTables < ActiveRecord::Migration[6.0]
+  def change
+    create_table :oauth_applications do |t|
+      t.references :owner, :null => false, :type => :bigint, :polymorphic => true
+      t.string :name, :null => false
+      t.string :uid, :null => false
+      t.string :secret, :null => false
+      t.text :redirect_uri, :null => false
+      t.string :scopes, :null => false, :default => ""
+      t.boolean :confidential, :null => false, :default => true
+      t.timestamps :null => false
+    end
+
+    add_index :oauth_applications, :uid, :unique => true
+    add_foreign_key :oauth_applications, :users, :column => :owner_id, :validate => false
+
+    create_table :oauth_access_grants do |t|
+      t.references :resource_owner, :null => false, :type => :bigint
+      t.references :application, :null => false
+      t.string :token, :null => false
+      t.integer :expires_in, :null => false
+      t.text :redirect_uri, :null => false
+      t.datetime :created_at, :null => false
+      t.datetime :revoked_at
+      t.string :scopes, :null => false, :default => ""
+      t.column :code_challenge, :string, :null => true
+      t.column :code_challenge_method, :string, :null => true
+    end
+
+    add_index :oauth_access_grants, :token, :unique => true
+    add_foreign_key :oauth_access_grants, :users, :column => :resource_owner_id, :validate => false
+    add_foreign_key :oauth_access_grants, :oauth_applications, :column => :application_id, :validate => false
+
+    create_table :oauth_access_tokens do |t|
+      t.references :resource_owner, :index => true, :type => :bigint
+      t.references :application, :null => false
+      t.string :token, :null => false
+      t.string :refresh_token
+      t.integer :expires_in
+      t.datetime :revoked_at
+      t.datetime :created_at, :null => false
+      t.string :scopes
+      t.string :previous_refresh_token, :null => false, :default => ""
+    end
+
+    add_index :oauth_access_tokens, :token, :unique => true
+    add_index :oauth_access_tokens, :refresh_token, :unique => true
+    add_foreign_key :oauth_access_tokens, :users, :column => :resource_owner_id, :validate => false
+    add_foreign_key :oauth_access_tokens, :oauth_applications, :column => :application_id, :validate => false
+  end
+end
index 53202f2d8fbf130ffa2175a776755fd0d59b8b52..a45bb0a7030d22c5609bf6a9d991dbe8d6d06eba 100644 (file)
@@ -1070,6 +1070,119 @@ CREATE SEQUENCE public.notes_id_seq
 ALTER SEQUENCE public.notes_id_seq OWNED BY public.notes.id;
 
 
+--
+-- Name: oauth_access_grants; Type: TABLE; Schema: public; Owner: -
+--
+
+CREATE TABLE public.oauth_access_grants (
+    id bigint NOT NULL,
+    resource_owner_id bigint NOT NULL,
+    application_id bigint NOT NULL,
+    token character varying NOT NULL,
+    expires_in integer NOT NULL,
+    redirect_uri text NOT NULL,
+    created_at timestamp without time zone NOT NULL,
+    revoked_at timestamp without time zone,
+    scopes character varying DEFAULT ''::character varying NOT NULL,
+    code_challenge character varying,
+    code_challenge_method character varying
+);
+
+
+--
+-- Name: oauth_access_grants_id_seq; Type: SEQUENCE; Schema: public; Owner: -
+--
+
+CREATE SEQUENCE public.oauth_access_grants_id_seq
+    START WITH 1
+    INCREMENT BY 1
+    NO MINVALUE
+    NO MAXVALUE
+    CACHE 1;
+
+
+--
+-- Name: oauth_access_grants_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
+--
+
+ALTER SEQUENCE public.oauth_access_grants_id_seq OWNED BY public.oauth_access_grants.id;
+
+
+--
+-- Name: oauth_access_tokens; Type: TABLE; Schema: public; Owner: -
+--
+
+CREATE TABLE public.oauth_access_tokens (
+    id bigint NOT NULL,
+    resource_owner_id bigint,
+    application_id bigint NOT NULL,
+    token character varying NOT NULL,
+    refresh_token character varying,
+    expires_in integer,
+    revoked_at timestamp without time zone,
+    created_at timestamp without time zone NOT NULL,
+    scopes character varying,
+    previous_refresh_token character varying DEFAULT ''::character varying NOT NULL
+);
+
+
+--
+-- Name: oauth_access_tokens_id_seq; Type: SEQUENCE; Schema: public; Owner: -
+--
+
+CREATE SEQUENCE public.oauth_access_tokens_id_seq
+    START WITH 1
+    INCREMENT BY 1
+    NO MINVALUE
+    NO MAXVALUE
+    CACHE 1;
+
+
+--
+-- Name: oauth_access_tokens_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
+--
+
+ALTER SEQUENCE public.oauth_access_tokens_id_seq OWNED BY public.oauth_access_tokens.id;
+
+
+--
+-- Name: oauth_applications; Type: TABLE; Schema: public; Owner: -
+--
+
+CREATE TABLE public.oauth_applications (
+    id bigint NOT NULL,
+    owner_type character varying NOT NULL,
+    owner_id bigint NOT NULL,
+    name character varying NOT NULL,
+    uid character varying NOT NULL,
+    secret character varying NOT NULL,
+    redirect_uri text NOT NULL,
+    scopes character varying DEFAULT ''::character varying NOT NULL,
+    confidential boolean DEFAULT true NOT NULL,
+    created_at timestamp(6) without time zone NOT NULL,
+    updated_at timestamp(6) without time zone NOT NULL
+);
+
+
+--
+-- Name: oauth_applications_id_seq; Type: SEQUENCE; Schema: public; Owner: -
+--
+
+CREATE SEQUENCE public.oauth_applications_id_seq
+    START WITH 1
+    INCREMENT BY 1
+    NO MINVALUE
+    NO MAXVALUE
+    CACHE 1;
+
+
+--
+-- Name: oauth_applications_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
+--
+
+ALTER SEQUENCE public.oauth_applications_id_seq OWNED BY public.oauth_applications.id;
+
+
 --
 -- Name: oauth_nonces; Type: TABLE; Schema: public; Owner: -
 --
@@ -1627,6 +1740,27 @@ ALTER TABLE ONLY public.note_comments ALTER COLUMN id SET DEFAULT nextval('publi
 ALTER TABLE ONLY public.notes ALTER COLUMN id SET DEFAULT nextval('public.notes_id_seq'::regclass);
 
 
+--
+-- Name: oauth_access_grants id; Type: DEFAULT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.oauth_access_grants ALTER COLUMN id SET DEFAULT nextval('public.oauth_access_grants_id_seq'::regclass);
+
+
+--
+-- Name: oauth_access_tokens id; Type: DEFAULT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.oauth_access_tokens ALTER COLUMN id SET DEFAULT nextval('public.oauth_access_tokens_id_seq'::regclass);
+
+
+--
+-- Name: oauth_applications id; Type: DEFAULT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.oauth_applications ALTER COLUMN id SET DEFAULT nextval('public.oauth_applications_id_seq'::regclass);
+
+
 --
 -- Name: oauth_nonces id; Type: DEFAULT; Schema: public; Owner: -
 --
@@ -1931,6 +2065,30 @@ ALTER TABLE ONLY public.notes
     ADD CONSTRAINT notes_pkey PRIMARY KEY (id);
 
 
+--
+-- Name: oauth_access_grants oauth_access_grants_pkey; Type: CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.oauth_access_grants
+    ADD CONSTRAINT oauth_access_grants_pkey PRIMARY KEY (id);
+
+
+--
+-- Name: oauth_access_tokens oauth_access_tokens_pkey; Type: CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.oauth_access_tokens
+    ADD CONSTRAINT oauth_access_tokens_pkey PRIMARY KEY (id);
+
+
+--
+-- Name: oauth_applications oauth_applications_pkey; Type: CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.oauth_applications
+    ADD CONSTRAINT oauth_applications_pkey PRIMARY KEY (id);
+
+
 --
 -- Name: oauth_nonces oauth_nonces_pkey; Type: CONSTRAINT; Schema: public; Owner: -
 --
@@ -2395,6 +2553,69 @@ CREATE INDEX index_note_comments_on_body ON public.note_comments USING gin (to_t
 CREATE INDEX index_note_comments_on_created_at ON public.note_comments USING btree (created_at);
 
 
+--
+-- Name: index_oauth_access_grants_on_application_id; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX index_oauth_access_grants_on_application_id ON public.oauth_access_grants USING btree (application_id);
+
+
+--
+-- Name: index_oauth_access_grants_on_resource_owner_id; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX index_oauth_access_grants_on_resource_owner_id ON public.oauth_access_grants USING btree (resource_owner_id);
+
+
+--
+-- Name: index_oauth_access_grants_on_token; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE UNIQUE INDEX index_oauth_access_grants_on_token ON public.oauth_access_grants USING btree (token);
+
+
+--
+-- Name: index_oauth_access_tokens_on_application_id; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX index_oauth_access_tokens_on_application_id ON public.oauth_access_tokens USING btree (application_id);
+
+
+--
+-- Name: index_oauth_access_tokens_on_refresh_token; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE UNIQUE INDEX index_oauth_access_tokens_on_refresh_token ON public.oauth_access_tokens USING btree (refresh_token);
+
+
+--
+-- Name: index_oauth_access_tokens_on_resource_owner_id; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX index_oauth_access_tokens_on_resource_owner_id ON public.oauth_access_tokens USING btree (resource_owner_id);
+
+
+--
+-- Name: index_oauth_access_tokens_on_token; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE UNIQUE INDEX index_oauth_access_tokens_on_token ON public.oauth_access_tokens USING btree (token);
+
+
+--
+-- Name: index_oauth_applications_on_owner_type_and_owner_id; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX index_oauth_applications_on_owner_type_and_owner_id ON public.oauth_applications USING btree (owner_type, owner_id);
+
+
+--
+-- Name: index_oauth_applications_on_uid; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE UNIQUE INDEX index_oauth_applications_on_uid ON public.oauth_applications USING btree (uid);
+
+
 --
 -- Name: index_oauth_nonces_on_nonce_and_timestamp; Type: INDEX; Schema: public; Owner: -
 --
@@ -2802,6 +3023,22 @@ ALTER TABLE ONLY public.diary_entry_subscriptions
     ADD CONSTRAINT diary_entry_subscriptions_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id);
 
 
+--
+-- Name: oauth_access_grants fk_rails_330c32d8d9; Type: FK CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.oauth_access_grants
+    ADD CONSTRAINT fk_rails_330c32d8d9 FOREIGN KEY (resource_owner_id) REFERENCES public.users(id) NOT VALID;
+
+
+--
+-- Name: oauth_access_tokens fk_rails_732cb83ab7; Type: FK CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.oauth_access_tokens
+    ADD CONSTRAINT fk_rails_732cb83ab7 FOREIGN KEY (application_id) REFERENCES public.oauth_applications(id) NOT VALID;
+
+
 --
 -- Name: active_storage_variant_records fk_rails_993965df05; Type: FK CONSTRAINT; Schema: public; Owner: -
 --
@@ -2810,6 +3047,14 @@ ALTER TABLE ONLY public.active_storage_variant_records
     ADD CONSTRAINT fk_rails_993965df05 FOREIGN KEY (blob_id) REFERENCES public.active_storage_blobs(id);
 
 
+--
+-- Name: oauth_access_grants fk_rails_b4b53e07b8; Type: FK CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.oauth_access_grants
+    ADD CONSTRAINT fk_rails_b4b53e07b8 FOREIGN KEY (application_id) REFERENCES public.oauth_applications(id) NOT VALID;
+
+
 --
 -- Name: active_storage_attachments fk_rails_c3b3935057; Type: FK CONSTRAINT; Schema: public; Owner: -
 --
@@ -2818,6 +3063,22 @@ ALTER TABLE ONLY public.active_storage_attachments
     ADD CONSTRAINT fk_rails_c3b3935057 FOREIGN KEY (blob_id) REFERENCES public.active_storage_blobs(id);
 
 
+--
+-- Name: oauth_applications fk_rails_cc886e315a; Type: FK CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.oauth_applications
+    ADD CONSTRAINT fk_rails_cc886e315a FOREIGN KEY (owner_id) REFERENCES public.users(id) NOT VALID;
+
+
+--
+-- Name: oauth_access_tokens fk_rails_ee63f25419; Type: FK CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.oauth_access_tokens
+    ADD CONSTRAINT fk_rails_ee63f25419 FOREIGN KEY (resource_owner_id) REFERENCES public.users(id) NOT VALID;
+
+
 --
 -- Name: friends friends_friend_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
 --
@@ -3181,6 +3442,7 @@ INSERT INTO "schema_migrations" (version) VALUES
 ('20190702193519'),
 ('20190716173946'),
 ('20191120140058'),
+('20201004105659'),
 ('20201006213836'),
 ('20201006220807'),
 ('20201214144017'),
diff --git a/lib/oauth.rb b/lib/oauth.rb
new file mode 100644 (file)
index 0000000..8f45a3b
--- /dev/null
@@ -0,0 +1,19 @@
+module Oauth
+  SCOPES = %w[read_prefs write_prefs write_diary write_api read_gpx write_gpx write_notes].freeze
+
+  class Scope
+    attr_reader :name
+
+    def initialize(name)
+      @name = name
+    end
+
+    def description
+      I18n.t("oauth.scopes.#{name}")
+    end
+  end
+
+  def self.scopes
+    SCOPES.collect { |s| Scope.new(s) }
+  end
+end
diff --git a/test/controllers/oauth2_applications_controller_test.rb b/test/controllers/oauth2_applications_controller_test.rb
new file mode 100644 (file)
index 0000000..eec5e02
--- /dev/null
@@ -0,0 +1,221 @@
+require "test_helper"
+
+class Oauth2ApplicationsControllerTest < ActionDispatch::IntegrationTest
+  ##
+  # test all routes which lead to this controller
+  def test_routes
+    assert_routing(
+      { :path => "/oauth2/applications", :method => :get },
+      { :controller => "oauth2_applications", :action => "index" }
+    )
+    assert_routing(
+      { :path => "/oauth2/applications", :method => :post },
+      { :controller => "oauth2_applications", :action => "create" }
+    )
+    assert_routing(
+      { :path => "/oauth2/applications/new", :method => :get },
+      { :controller => "oauth2_applications", :action => "new" }
+    )
+    assert_routing(
+      { :path => "/oauth2/applications/1/edit", :method => :get },
+      { :controller => "oauth2_applications", :action => "edit", :id => "1" }
+    )
+    assert_routing(
+      { :path => "/oauth2/applications/1", :method => :get },
+      { :controller => "oauth2_applications", :action => "show", :id => "1" }
+    )
+    assert_routing(
+      { :path => "/oauth2/applications/1", :method => :patch },
+      { :controller => "oauth2_applications", :action => "update", :id => "1" }
+    )
+    assert_routing(
+      { :path => "/oauth2/applications/1", :method => :put },
+      { :controller => "oauth2_applications", :action => "update", :id => "1" }
+    )
+    assert_routing(
+      { :path => "/oauth2/applications/1", :method => :delete },
+      { :controller => "oauth2_applications", :action => "destroy", :id => "1" }
+    )
+  end
+
+  def test_index
+    user = create(:user)
+    create_list(:oauth_application, 2, :owner => user)
+
+    get oauth_applications_path
+    assert_response :redirect
+    assert_redirected_to login_path(:referer => oauth_applications_path)
+
+    session_for(user)
+
+    get oauth_applications_path
+    assert_response :success
+    assert_template "oauth2_applications/index"
+    assert_select "tr", 2
+  end
+
+  def test_new
+    user = create(:user)
+
+    get new_oauth_application_path
+    assert_response :redirect
+    assert_redirected_to login_path(:referer => new_oauth_application_path)
+
+    session_for(user)
+
+    get new_oauth_application_path
+    assert_response :success
+    assert_template "oauth2_applications/new"
+    assert_select "form", 1 do
+      assert_select "input#doorkeeper_application_name", 1
+      assert_select "textarea#doorkeeper_application_redirect_uri", 1
+      assert_select "input#doorkeeper_application_confidential", 1
+      Oauth.scopes.each do |scope|
+        assert_select "input#doorkeeper_application_scopes_#{scope.name}", 1
+      end
+    end
+  end
+
+  def test_create
+    user = create(:user)
+
+    assert_difference "Doorkeeper::Application.count", 0 do
+      post oauth_applications_path
+    end
+    assert_response :forbidden
+
+    session_for(user)
+
+    assert_difference "Doorkeeper::Application.count", 0 do
+      post oauth_applications_path(:doorkeeper_application => {
+                                     :name => "Test Application"
+                                   })
+    end
+    assert_response :success
+    assert_template "oauth2_applications/new"
+
+    assert_difference "Doorkeeper::Application.count", 0 do
+      post oauth_applications_path(:doorkeeper_application => {
+                                     :name => "Test Application",
+                                     :redirect_uri => "https://test.example.com/",
+                                     :scopes => ["bad_scope"]
+                                   })
+    end
+    assert_response :success
+    assert_template "oauth2_applications/new"
+
+    assert_difference "Doorkeeper::Application.count", 1 do
+      post oauth_applications_path(:doorkeeper_application => {
+                                     :name => "Test Application",
+                                     :redirect_uri => "https://test.example.com/",
+                                     :scopes => ["read_prefs"]
+                                   })
+    end
+    assert_response :redirect
+    assert_redirected_to oauth_application_path(:id => Doorkeeper::Application.find_by(:name => "Test Application").id)
+  end
+
+  def test_show
+    user = create(:user)
+    client = create(:oauth_application, :owner => user)
+    other_client = create(:oauth_application)
+
+    get oauth_application_path(:id => client)
+    assert_response :redirect
+    assert_redirected_to login_path(:referer => oauth_application_path(:id => client.id))
+
+    session_for(user)
+
+    get oauth_application_path(:id => other_client)
+    assert_response :not_found
+    assert_template "oauth2_applications/not_found"
+
+    get oauth_application_path(:id => client)
+    assert_response :success
+    assert_template "oauth2_applications/show"
+  end
+
+  def test_edit
+    user = create(:user)
+    client = create(:oauth_application, :owner => user)
+    other_client = create(:oauth_application)
+
+    get edit_oauth_application_path(:id => client)
+    assert_response :redirect
+    assert_redirected_to login_path(:referer => edit_oauth_application_path(:id => client.id))
+
+    session_for(user)
+
+    get edit_oauth_application_path(:id => other_client)
+    assert_response :not_found
+    assert_template "oauth2_applications/not_found"
+
+    get edit_oauth_application_path(:id => client)
+    assert_response :success
+    assert_template "oauth2_applications/edit"
+    assert_select "form", 1 do
+      assert_select "input#doorkeeper_application_name", 1
+      assert_select "textarea#doorkeeper_application_redirect_uri", 1
+      assert_select "input#doorkeeper_application_confidential", 1
+      Oauth.scopes.each do |scope|
+        assert_select "input#doorkeeper_application_scopes_#{scope.name}", 1
+      end
+    end
+  end
+
+  def test_update
+    user = create(:user)
+    client = create(:oauth_application, :owner => user)
+    other_client = create(:oauth_application)
+
+    put oauth_application_path(:id => client)
+    assert_response :forbidden
+
+    session_for(user)
+
+    put oauth_application_path(:id => other_client)
+    assert_response :not_found
+    assert_template "oauth2_applications/not_found"
+
+    put oauth_application_path(:id => client,
+                               :doorkeeper_application => {
+                                 :name => "New Name",
+                                 :redirect_uri => nil
+                               })
+    assert_response :success
+    assert_template "oauth2_applications/edit"
+
+    put oauth_application_path(:id => client,
+                               :doorkeeper_application => {
+                                 :name => "New Name",
+                                 :redirect_uri => "https://new.example.com/url"
+                               })
+    assert_response :redirect
+    assert_redirected_to oauth_application_path(:id => client.id)
+  end
+
+  def test_destroy
+    user = create(:user)
+    client = create(:oauth_application, :owner => user)
+    other_client = create(:oauth_application)
+
+    assert_difference "Doorkeeper::Application.count", 0 do
+      delete oauth_application_path(:id => client)
+    end
+    assert_response :forbidden
+
+    session_for(user)
+
+    assert_difference "Doorkeeper::Application.count", 0 do
+      delete oauth_application_path(:id => other_client)
+    end
+    assert_response :not_found
+    assert_template "oauth2_applications/not_found"
+
+    assert_difference "Doorkeeper::Application.count", -1 do
+      delete oauth_application_path(:id => client)
+    end
+    assert_response :redirect
+    assert_redirected_to oauth_applications_path
+  end
+end
diff --git a/test/controllers/oauth2_authorizations_controller_test.rb b/test/controllers/oauth2_authorizations_controller_test.rb
new file mode 100644 (file)
index 0000000..19bc798
--- /dev/null
@@ -0,0 +1,184 @@
+require "test_helper"
+
+class Oauth2AuthorizationsControllerTest < ActionDispatch::IntegrationTest
+  ##
+  # test all routes which lead to this controller
+  def test_routes
+    assert_routing(
+      { :path => "/oauth2/authorize", :method => :get },
+      { :controller => "oauth2_authorizations", :action => "new" }
+    )
+    assert_routing(
+      { :path => "/oauth2/authorize", :method => :post },
+      { :controller => "oauth2_authorizations", :action => "create" }
+    )
+    assert_routing(
+      { :path => "/oauth2/authorize", :method => :delete },
+      { :controller => "oauth2_authorizations", :action => "destroy" }
+    )
+    assert_routing(
+      { :path => "/oauth2/authorize/native", :method => :get },
+      { :controller => "oauth2_authorizations", :action => "show" }
+    )
+  end
+
+  def test_new
+    application = create(:oauth_application, :scopes => "write_api")
+
+    get oauth_authorization_path(:client_id => application.uid,
+                                 :redirect_uri => application.redirect_uri,
+                                 :response_type => "code",
+                                 :scope => "write_api")
+    assert_response :redirect
+    assert_redirected_to login_path(:referer => oauth_authorization_path(:client_id => application.uid,
+                                                                         :redirect_uri => application.redirect_uri,
+                                                                         :response_type => "code",
+                                                                         :scope => "write_api"))
+
+    session_for(create(:user))
+
+    get oauth_authorization_path(:client_id => application.uid,
+                                 :redirect_uri => application.redirect_uri,
+                                 :response_type => "code",
+                                 :scope => "write_api")
+    assert_response :success
+    assert_template "oauth2_authorizations/new"
+  end
+
+  def test_new_native
+    application = create(:oauth_application, :scopes => "write_api", :redirect_uri => "urn:ietf:wg:oauth:2.0:oob")
+
+    get oauth_authorization_path(:client_id => application.uid,
+                                 :redirect_uri => application.redirect_uri,
+                                 :response_type => "code",
+                                 :scope => "write_api")
+    assert_response :redirect
+    assert_redirected_to login_path(:referer => oauth_authorization_path(:client_id => application.uid,
+                                                                         :redirect_uri => application.redirect_uri,
+                                                                         :response_type => "code",
+                                                                         :scope => "write_api"))
+
+    session_for(create(:user))
+
+    get oauth_authorization_path(:client_id => application.uid,
+                                 :redirect_uri => application.redirect_uri,
+                                 :response_type => "code",
+                                 :scope => "write_api")
+    assert_response :success
+    assert_template "oauth2_authorizations/new"
+  end
+
+  def test_new_bad_uri
+    application = create(:oauth_application, :scopes => "write_api")
+
+    session_for(create(:user))
+
+    get oauth_authorization_path(:client_id => application.uid,
+                                 :redirect_uri => "https://bad.example.com/",
+                                 :response_type => "code",
+                                 :scope => "write_api")
+    assert_response :success
+    assert_template "oauth2_authorizations/error"
+    assert_select "p", "The requested redirect uri is malformed or doesn't match client redirect URI."
+  end
+
+  def test_new_bad_scope
+    application = create(:oauth_application, :scopes => "write_api")
+
+    session_for(create(:user))
+
+    get oauth_authorization_path(:client_id => application.uid,
+                                 :redirect_uri => application.redirect_uri,
+                                 :response_type => "code",
+                                 :scope => "bad_scope")
+    assert_response :success
+    assert_template "oauth2_authorizations/error"
+    assert_select "p", "The requested scope is invalid, unknown, or malformed."
+
+    get oauth_authorization_path(:client_id => application.uid,
+                                 :redirect_uri => application.redirect_uri,
+                                 :response_type => "code",
+                                 :scope => "write_prefs")
+    assert_response :success
+    assert_template "oauth2_authorizations/error"
+    assert_select "p", "The requested scope is invalid, unknown, or malformed."
+  end
+
+  def test_create
+    application = create(:oauth_application, :scopes => "write_api")
+
+    post oauth_authorization_path(:client_id => application.uid,
+                                  :redirect_uri => application.redirect_uri,
+                                  :response_type => "code",
+                                  :scope => "write_api")
+    assert_response :forbidden
+
+    session_for(create(:user))
+
+    post oauth_authorization_path(:client_id => application.uid,
+                                  :redirect_uri => application.redirect_uri,
+                                  :response_type => "code",
+                                  :scope => "write_api")
+    assert_response :redirect
+    assert_redirected_to(/^#{Regexp.escape(application.redirect_uri)}\?code=/)
+  end
+
+  def test_create_native
+    application = create(:oauth_application, :scopes => "write_api", :redirect_uri => "urn:ietf:wg:oauth:2.0:oob")
+
+    post oauth_authorization_path(:client_id => application.uid,
+                                  :redirect_uri => application.redirect_uri,
+                                  :response_type => "code",
+                                  :scope => "write_api")
+    assert_response :forbidden
+
+    session_for(create(:user))
+
+    post oauth_authorization_path(:client_id => application.uid,
+                                  :redirect_uri => application.redirect_uri,
+                                  :response_type => "code",
+                                  :scope => "write_api")
+    assert_response :redirect
+    assert_equal native_oauth_authorization_path, URI.parse(response.location).path
+    follow_redirect!
+    assert_response :success
+    assert_template "oauth2_authorizations/show"
+  end
+
+  def test_destroy
+    application = create(:oauth_application)
+
+    delete oauth_authorization_path(:client_id => application.uid,
+                                    :redirect_uri => application.redirect_uri,
+                                    :response_type => "code",
+                                    :scope => "write_api")
+    assert_response :forbidden
+
+    session_for(create(:user))
+
+    delete oauth_authorization_path(:client_id => application.uid,
+                                    :redirect_uri => application.redirect_uri,
+                                    :response_type => "code",
+                                    :scope => "write_api")
+    assert_response :redirect
+    assert_redirected_to(/^#{Regexp.escape(application.redirect_uri)}\?error=access_denied/)
+  end
+
+  def test_destroy_native
+    application = create(:oauth_application, :redirect_uri => "urn:ietf:wg:oauth:2.0:oob")
+
+    delete oauth_authorization_path(:client_id => application.uid,
+                                    :redirect_uri => application.redirect_uri,
+                                    :response_type => "code",
+                                    :scope => "write_api")
+    assert_response :forbidden
+
+    session_for(create(:user))
+
+    delete oauth_authorization_path(:client_id => application.uid,
+                                    :redirect_uri => application.redirect_uri,
+                                    :response_type => "code",
+                                    :scope => "write_api")
+    assert_response :bad_request
+  end
+end
diff --git a/test/controllers/oauth2_authorized_applications_controller_test.rb b/test/controllers/oauth2_authorized_applications_controller_test.rb
new file mode 100644 (file)
index 0000000..45a60ef
--- /dev/null
@@ -0,0 +1,63 @@
+require "test_helper"
+
+class Oauth2AuthorizedApplicationsControllerTest < ActionDispatch::IntegrationTest
+  ##
+  # test all routes which lead to this controller
+  def test_routes
+    assert_routing(
+      { :path => "/oauth2/authorized_applications", :method => :get },
+      { :controller => "oauth2_authorized_applications", :action => "index" }
+    )
+    assert_routing(
+      { :path => "/oauth2/authorized_applications/1", :method => :delete },
+      { :controller => "oauth2_authorized_applications", :action => "destroy", :id => "1" }
+    )
+  end
+
+  def test_index
+    user = create(:user)
+    application1 = create(:oauth_application)
+    create(:oauth_access_grant, :resource_owner_id => user.id, :application => application1)
+    create(:oauth_access_token, :resource_owner_id => user.id, :application => application1)
+    application2 = create(:oauth_application)
+    create(:oauth_access_grant, :resource_owner_id => user.id, :application => application2)
+    create(:oauth_access_token, :resource_owner_id => user.id, :application => application2)
+    create(:oauth_application)
+
+    get oauth_authorized_applications_path
+    assert_response :redirect
+    assert_redirected_to login_path(:referer => oauth_authorized_applications_path)
+
+    session_for(user)
+
+    get oauth_authorized_applications_path
+    assert_response :success
+    assert_template "oauth2_authorized_applications/index"
+    assert_select "tr", 2
+  end
+
+  def test_destroy
+    user = create(:user)
+    application1 = create(:oauth_application)
+    create(:oauth_access_grant, :resource_owner_id => user.id, :application => application1)
+    create(:oauth_access_token, :resource_owner_id => user.id, :application => application1)
+    application2 = create(:oauth_application)
+    create(:oauth_access_grant, :resource_owner_id => user.id, :application => application2)
+    create(:oauth_access_token, :resource_owner_id => user.id, :application => application2)
+    create(:oauth_application)
+
+    delete oauth_authorized_application_path(:id => application1.id)
+    assert_response :forbidden
+
+    session_for(user)
+
+    delete oauth_authorized_application_path(:id => application1.id)
+    assert_response :redirect
+    assert_redirected_to oauth_authorized_applications_path
+
+    get oauth_authorized_applications_path
+    assert_response :success
+    assert_template "oauth2_authorized_applications/index"
+    assert_select "tr", 1
+  end
+end
diff --git a/test/factories/oauth_access_grant.rb b/test/factories/oauth_access_grant.rb
new file mode 100644 (file)
index 0000000..caddea8
--- /dev/null
@@ -0,0 +1,9 @@
+FactoryBot.define do
+  factory :oauth_access_grant, :class => "Doorkeeper::AccessGrant" do
+    association :resource_owner_id, :factory => :user
+    association :application, :factory => :oauth_application
+
+    expires_in { 86400 }
+    redirect_uri { application.redirect_uri }
+  end
+end
diff --git a/test/factories/oauth_access_token.rb b/test/factories/oauth_access_token.rb
new file mode 100644 (file)
index 0000000..c0f6245
--- /dev/null
@@ -0,0 +1,6 @@
+FactoryBot.define do
+  factory :oauth_access_token, :class => "Doorkeeper::AccessToken" do
+    association :resource_owner_id, :factory => :user
+    association :application, :factory => :oauth_application
+  end
+end
diff --git a/test/factories/oauth_applications.rb b/test/factories/oauth_applications.rb
new file mode 100644 (file)
index 0000000..a9b3b87
--- /dev/null
@@ -0,0 +1,8 @@
+FactoryBot.define do
+  factory :oauth_application, :class => "Doorkeeper::Application" do
+    sequence(:name) { |n| "OAuth application #{n}" }
+    sequence(:redirect_uri) { |n| "https://example.com/app/#{n}" }
+
+    association :owner, :factory => :user
+  end
+end
diff --git a/test/integration/oauth2_test.rb b/test/integration/oauth2_test.rb
new file mode 100644 (file)
index 0000000..8de381c
--- /dev/null
@@ -0,0 +1,170 @@
+require "test_helper"
+
+class OAuth2Test < ActionDispatch::IntegrationTest
+  def test_oauth2
+    client = create(:oauth_application, :redirect_uri => "https://some.web.app.example.org/callback", :scopes => "read_prefs write_api read_gpx")
+    state = SecureRandom.urlsafe_base64(16)
+
+    authorize_client(client, :state => state)
+    assert_response :redirect
+    code = validate_redirect(client, state)
+
+    token = request_token(client, code)
+
+    test_token(token, client)
+  end
+
+  def test_oauth2_oob
+    client = create(:oauth_application, :redirect_uri => "urn:ietf:wg:oauth:2.0:oob", :scopes => "read_prefs write_api read_gpx")
+
+    authorize_client(client)
+    assert_response :redirect
+    follow_redirect!
+    assert_response :success
+    assert_template "oauth2_authorizations/show"
+    m = response.body.match(%r{<code id="authorization_code">([A-Za-z0-9_-]+)</code>})
+    assert_not_nil m
+    code = m[1]
+
+    token = request_token(client, code)
+
+    test_token(token, client)
+  end
+
+  def test_oauth2_pkce_plain
+    client = create(:oauth_application, :redirect_uri => "https://some.web.app.example.org/callback", :scopes => "read_prefs write_api read_gpx")
+    state = SecureRandom.urlsafe_base64(16)
+    verifier = SecureRandom.urlsafe_base64(48)
+    challenge = verifier
+
+    authorize_client(client, :state => state, :code_challenge => challenge, :code_challenge_method => "plain")
+    assert_response :redirect
+    code = validate_redirect(client, state)
+
+    token = request_token(client, code, verifier)
+
+    test_token(token, client)
+  end
+
+  def test_oauth2_pkce_s256
+    client = create(:oauth_application, :redirect_uri => "https://some.web.app.example.org/callback", :scopes => "read_prefs write_api read_gpx")
+    state = SecureRandom.urlsafe_base64(16)
+    verifier = SecureRandom.urlsafe_base64(48)
+    challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), :padding => false)
+
+    authorize_client(client, :state => state, :code_challenge => challenge, :code_challenge_method => "S256")
+    assert_response :redirect
+    code = validate_redirect(client, state)
+
+    token = request_token(client, code, verifier)
+
+    test_token(token, client)
+  end
+
+  private
+
+  def authorize_client(client, options = {})
+    options = options.merge(:client_id => client.uid,
+                            :redirect_uri => client.redirect_uri,
+                            :response_type => "code",
+                            :scope => "read_prefs")
+
+    get oauth_authorization_path(options)
+    assert_response :redirect
+    assert_redirected_to login_path(:referer => request.fullpath)
+
+    user = create(:user)
+
+    post login_path(:username => user.email, :password => "test")
+    follow_redirect!
+    assert_response :success
+
+    get oauth_authorization_path(options)
+    assert_response :success
+    assert_template "oauth2_authorizations/new"
+
+    delete oauth_authorization_path(options)
+
+    validate_deny(client, options)
+
+    post oauth_authorization_path(options)
+  end
+
+  def validate_deny(client, options)
+    if client.redirect_uri == "urn:ietf:wg:oauth:2.0:oob"
+      assert_response :bad_request
+    else
+      assert_response :redirect
+      location = URI.parse(response.location)
+      assert_match(/^#{Regexp.escape(client.redirect_uri)}/, location.to_s)
+      query = Rack::Utils.parse_query(location.query)
+      assert_equal "access_denied", query["error"]
+      assert_equal "The resource owner or authorization server denied the request.", query["error_description"]
+      assert_equal options[:state], query["state"]
+    end
+  end
+
+  def validate_redirect(client, state)
+    location = URI.parse(response.location)
+    assert_match(/^#{Regexp.escape(client.redirect_uri)}/, location.to_s)
+    query = Rack::Utils.parse_query(location.query)
+    assert_equal state, query["state"]
+
+    query["code"]
+  end
+
+  def request_token(client, code, verifier = nil)
+    options = {
+      :client_id => client.uid,
+      :client_secret => client.plaintext_secret,
+      :code => code,
+      :grant_type => "authorization_code",
+      :redirect_uri => client.redirect_uri
+    }
+
+    if verifier
+      post oauth_token_path(options)
+      assert_response :bad_request
+
+      options = options.merge(:code_verifier => verifier)
+    end
+
+    post oauth_token_path(options)
+    assert_response :success
+    token = JSON.parse(response.body)
+    assert_equal "Bearer", token["token_type"]
+    assert_equal "read_prefs", token["scope"]
+
+    token["access_token"]
+  end
+
+  def test_token(token, client)
+    get user_preferences_path
+    assert_response :unauthorized
+
+    auth_header = bearer_authorization_header(token)
+
+    get user_preferences_path, :headers => auth_header
+    assert_response :success
+
+    get user_preferences_path(:access_token => token)
+    assert_response :unauthorized
+
+    get user_preferences_path(:bearer_token => token)
+    assert_response :unauthorized
+
+    get api_trace_path(:id => 2), :headers => auth_header
+    assert_response :forbidden
+
+    post oauth_revoke_path(:token => token)
+    assert_response :forbidden
+
+    post oauth_revoke_path(:token => token,
+                           :client_id => client.uid,
+                           :client_secret => client.plaintext_secret)
+    assert_response :success
+
+    get user_preferences_path, :headers => auth_header
+    assert_response :unauthorized
+  end
+end
index a6147ef29054e817dbe27d7cd9b3c5a146dcf6a6..505fa256876b2a9000965d59fda9923b67f6580d 100644 (file)
@@ -138,6 +138,12 @@ module ActiveSupport
       { "Authorization" => format("Basic %<auth>s", :auth => Base64.encode64("#{user}:#{pass}")) }
     end
 
+    ##
+    # return request header for HTTP Bearer Authorization
+    def bearer_authorization_header(token)
+      { "Authorization" => "Bearer #{token}" }
+    end
+
     ##
     # make an OAuth signed request
     def signed_request(method, uri, options = {})