diff --git a/app/controllers/oauth_applications_controller.rb b/app/controllers/oauth_applications_controller.rb
new file mode 100644
index 0000000000..960e6f2728
--- /dev/null
+++ b/app/controllers/oauth_applications_controller.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+class OauthApplicationsController < Doorkeeper::ApplicationsController
+ skip_after_action :verify_authorized # access is gated by Doorkeeper's admin_authenticator
+
+ private
+
+ def application_params
+ owner_email = params.dig(:doorkeeper_application, :owner_email)
+ return super if owner_email.nil?
+
+ @owner ||= User.find_by(email: owner_email)
+ super.merge(owner: @owner)
+ end
+
+end
diff --git a/app/views/doorkeeper/applications/_form.html.erb b/app/views/doorkeeper/applications/_form.html.erb
new file mode 100644
index 0000000000..284b52c0f3
--- /dev/null
+++ b/app/views/doorkeeper/applications/_form.html.erb
@@ -0,0 +1,70 @@
+<%= form_for application, url: doorkeeper_submit_path(application), as: :doorkeeper_application, html: { role: "form" } do |f| %>
+ <% if application.errors.any? %>
+
<%= t("doorkeeper.applications.form.error") %>
+ <% end %>
+
+
+
+
+
+
+
+
+
+
+
+
+<% end %>
diff --git a/app/views/doorkeeper/applications/index.html.erb b/app/views/doorkeeper/applications/index.html.erb
new file mode 100644
index 0000000000..3604ab7309
--- /dev/null
+++ b/app/views/doorkeeper/applications/index.html.erb
@@ -0,0 +1,46 @@
+
+
<%= t(".title") %>
+
+
+<%= link_to t(".new"), new_oauth_application_path, class: "btn btn-success" %>
+
+
+
+
+ | <%= t(".name") %> |
+ Point of contact |
+ <%= t(".callback_url") %> |
+ <%= t(".confidential") %> |
+ <%= t(".actions") %> |
+ |
+
+
+
+ <% @applications.each do |application| %>
+
+ |
+ <%= link_to application.name, oauth_application_path(application) %>
+ |
+
+ <% if application.owner %>
+ <%= link_to application.owner.name, edit_user_path(application.owner) %> (<%= application.owner.email %>)
+ <% else %>
+ Unassigned
+ <% end %>
+ |
+
+ <%= simple_format(application.redirect_uri) %>
+ |
+
+ <%= application.confidential? ? t("doorkeeper.applications.index.confidentiality.yes") : t("doorkeeper.applications.index.confidentiality.no") %>
+ |
+
+ <%= link_to t("doorkeeper.applications.buttons.edit"), edit_oauth_application_path(application), class: "btn btn-link" %>
+ |
+
+ <%= render "doorkeeper/applications/delete_form", application: application %>
+ |
+
+ <% end %>
+
+
diff --git a/app/views/doorkeeper/applications/show.html.erb b/app/views/doorkeeper/applications/show.html.erb
new file mode 100644
index 0000000000..7d5e3e418b
--- /dev/null
+++ b/app/views/doorkeeper/applications/show.html.erb
@@ -0,0 +1,72 @@
+
+
<%= t(".title", name: @application.name) %>
+
+
+
+
+
Point of contact:
+
+ <% if @application.owner %>
+ <%= link_to @application.owner.name, edit_user_path(@application.owner) %> (<%= @application.owner.email %>)
+ <% else %>
+ Unassigned
+ <% end %>
+
+
+
<%= t(".application_id") %>:
+
<%= @application.uid %>
+
+
<%= t(".secret") %>:
+
+
+ <% secret = flash[:application_secret].presence || @application.plaintext_secret %>
+ <% if secret.blank? && Doorkeeper.config.application_secret_hashed? %>
+ <%= t(".secret_hashed") %>
+ <% else %>
+ <%= secret %>
+ <% end %>
+
+
+
+
<%= t(".scopes") %>:
+
+
+ <% if @application.scopes.present? %>
+ <%= @application.scopes %>
+ <% else %>
+ <%= t(".not_defined") %>
+ <% end %>
+
+
+
+
<%= t(".confidential") %>:
+
<%= @application.confidential? %>
+
+
<%= t(".callback_urls") %>:
+
+ <% if @application.redirect_uri.present? %>
+
+ <% @application.redirect_uri.split.each do |uri| %>
+
+
+ <%= uri %>
+ |
+
+ <%= link_to t("doorkeeper.applications.buttons.authorize"), oauth_authorization_path(client_id: @application.uid, redirect_uri: uri, response_type: "code", scope: @application.scopes), class: "btn btn-success", target: "_blank" %>
+ |
+
+ <% end %>
+
+ <% else %>
+
<%= t(".not_defined") %>
+ <% end %>
+
+
+
+
<%= t(".actions") %>
+
+
<%= link_to t("doorkeeper.applications.buttons.edit"), edit_oauth_application_path(@application), class: "btn btn-primary" %>
+
+
<%= render "doorkeeper/applications/delete_form", application: @application, submit_btn_css: "btn btn-danger" %>
+
+
diff --git a/config/initializers/doorkeeper.rb b/config/initializers/doorkeeper.rb
index b05d041c5b..3dd3754139 100644
--- a/config/initializers/doorkeeper.rb
+++ b/config/initializers/doorkeeper.rb
@@ -176,6 +176,8 @@
use_refresh_token
+ enable_application_owner confirmation: true
+
# 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
diff --git a/config/locales/doorkeeper.en.yml b/config/locales/doorkeeper.en.yml
index 99fa3d4d89..c4035e759e 100644
--- a/config/locales/doorkeeper.en.yml
+++ b/config/locales/doorkeeper.en.yml
@@ -4,10 +4,13 @@ en:
doorkeeper/application:
name: 'Name'
redirect_uri: 'Redirect URI'
+ owner: 'Point of contact'
errors:
models:
doorkeeper/application:
attributes:
+ owner:
+ blank: 'must be the email of an existing user.'
redirect_uri:
fragment_present: 'cannot contain a fragment.'
invalid_uri: 'must be a valid URI.'
diff --git a/config/routes.rb b/config/routes.rb
index 8d3662e6ab..73fbedeea2 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -713,6 +713,7 @@
use_doorkeeper scope: "api/v4/oauth" do
skip_controllers :authorized_applications
+ controllers applications: "oauth_applications"
end
use_doorkeeper_device_authorization_grant scope: "api/v4/oauth"
diff --git a/db/migrate/20261102120000_add_owner_to_oauth_applications.rb b/db/migrate/20261102120000_add_owner_to_oauth_applications.rb
new file mode 100644
index 0000000000..1e78503d98
--- /dev/null
+++ b/db/migrate/20261102120000_add_owner_to_oauth_applications.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+class AddOwnerToOauthApplications < ActiveRecord::Migration[8.1]
+ disable_ddl_transaction!
+
+ def change
+ add_reference :oauth_applications, :owner, polymorphic: true, index: { algorithm: :concurrently }
+ end
+
+end
diff --git a/db/schema.rb b/db/schema.rb
index 6a53597196..0028cc16b4 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -12,7 +12,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.1].define(version: 2026_10_30_120000) do
+ActiveRecord::Schema[8.1].define(version: 2026_11_02_120000) do
# These are extensions that must be enabled in order to support this database
enable_extension "citext"
enable_extension "pg_catalog.plpgsql"
@@ -1852,12 +1852,15 @@
t.boolean "confidential", default: true, null: false
t.datetime "created_at", null: false
t.string "name", null: false
+ t.bigint "owner_id"
+ t.string "owner_type"
t.text "redirect_uri", null: false
t.string "scopes", default: "", null: false
t.string "secret", null: false
t.boolean "trusted", default: false, null: false
t.string "uid", null: false
t.datetime "updated_at", null: false
+ t.index ["owner_type", "owner_id"], name: "index_oauth_applications_on_owner"
t.index ["uid"], name: "index_oauth_applications_on_uid", unique: true
end
diff --git a/dev-docs/v4-api/standards.md b/dev-docs/v4-api/standards.md
index a1a322858f..b1e372d04e 100644
--- a/dev-docs/v4-api/standards.md
+++ b/dev-docs/v4-api/standards.md
@@ -57,7 +57,8 @@ app = Doorkeeper::Application.create(
name: "tester",
redirect_uri: "http://localhost:3000/",
scopes: ["read", "write"],
- confidential: false
+ confidential: false,
+ owner: User.find_by(email: "you@example.com") # point of contact, required
)
```
diff --git a/spec/controllers/api/v4/donations_controller_spec.rb b/spec/controllers/api/v4/donations_controller_spec.rb
index 1ddb99f7ab..fc21151d52 100644
--- a/spec/controllers/api/v4/donations_controller_spec.rb
+++ b/spec/controllers/api/v4/donations_controller_spec.rb
@@ -18,7 +18,7 @@
event = create(:event)
create(:organizer_position, user:, event:)
- trusted_app = Doorkeeper::Application.create!(name: "Trusted App", redirect_uri: "https://hcb.hackclub.com", trusted: true)
+ trusted_app = Doorkeeper::Application.create!(name: "Trusted App", redirect_uri: "https://hcb.hackclub.com", trusted: true, owner: user)
token = create(:api_token, user:, application: trusted_app)
request.headers["Authorization"] = "Bearer #{token.token}"
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
index 7c81e39019..9310ef8dbd 100644
--- a/spec/models/user_spec.rb
+++ b/spec/models/user_spec.rb
@@ -277,7 +277,7 @@
end
describe "#lock!" do
- let(:application) { Doorkeeper::Application.create!(name: "Test App", redirect_uri: "https://example.com/callback") }
+ let(:application) { Doorkeeper::Application.create!(name: "Test App", redirect_uri: "https://example.com/callback", owner: create(:user)) }
it "locks" do
user = create(:user, locked_at: nil)
diff --git a/spec/requests/api/v4/oauth_locked_user_spec.rb b/spec/requests/api/v4/oauth_locked_user_spec.rb
index 8e3c13d44e..6a766a191a 100644
--- a/spec/requests/api/v4/oauth_locked_user_spec.rb
+++ b/spec/requests/api/v4/oauth_locked_user_spec.rb
@@ -5,7 +5,7 @@
RSpec.describe "OAuth tokens of locked users", type: :request do
let(:user) { create(:user) }
let(:application) do
- Doorkeeper::Application.create!(name: "Test App", redirect_uri: "https://example.com/callback", scopes: "read")
+ Doorkeeper::Application.create!(name: "Test App", redirect_uri: "https://example.com/callback", owner: create(:user), scopes: "read")
end
# Access tokens expire after two hours, so by the time staff lock an account
# most of its OAuth tokens look like this: expired, unrevoked, and still
diff --git a/spec/requests/doorkeeper_impersonation_spec.rb b/spec/requests/doorkeeper_impersonation_spec.rb
index 279e9f25db..b175d3247f 100644
--- a/spec/requests/doorkeeper_impersonation_spec.rb
+++ b/spec/requests/doorkeeper_impersonation_spec.rb
@@ -6,7 +6,7 @@
let(:admin) { create(:user, :make_admin) }
let(:user) { create(:user) }
let(:application) do
- Doorkeeper::Application.create!(name: "Test App", redirect_uri: "https://example.com/callback", scopes: "read")
+ Doorkeeper::Application.create!(name: "Test App", redirect_uri: "https://example.com/callback", owner: create(:user), scopes: "read")
end
before do
diff --git a/spec/requests/oauth_applications_spec.rb b/spec/requests/oauth_applications_spec.rb
new file mode 100644
index 0000000000..cc63d5c319
--- /dev/null
+++ b/spec/requests/oauth_applications_spec.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+RSpec.describe "OAuth applications", type: :request do
+ let(:admin) { create(:user, :make_admin) }
+ let(:poc) { create(:user, email: "poc@hackclub.com") }
+
+ before do
+ admin_session = create(:user_session, user: admin, verified: true, expiration_at: 1.hour.from_now)
+
+ allow_any_instance_of(SessionsHelper)
+ .to receive(:find_current_session)
+ .and_return(admin_session)
+ end
+
+ def create_application(owner_email:)
+ post "/api/v4/oauth/applications", params: {
+ doorkeeper_application: { name: "Test App", redirect_uri: "https://example.com/callback", owner_email: }
+ }
+ end
+
+ it "assigns the point of contact by email" do
+ create_application(owner_email: poc.email)
+
+ expect(Doorkeeper::Application.sole.owner).to eq(poc)
+ end
+
+ it "requires a point of contact" do
+ create_application(owner_email: "nobody@example.com")
+
+ expect(Doorkeeper::Application.count).to eq(0)
+ expect(response.body).to include("Must be the email of an existing user.")
+ end
+
+ it "keeps the point of contact when updating without an email" do
+ application = Doorkeeper::Application.create!(name: "Test App", redirect_uri: "https://example.com/callback", owner: poc)
+
+ patch "/api/v4/oauth/applications/#{application.id}", params: { doorkeeper_application: { name: "Renamed" } }
+
+ expect(application.reload).to have_attributes(name: "Renamed", owner: poc)
+ end
+
+ it "shows the point of contact" do
+ application = Doorkeeper::Application.create!(name: "Test App", redirect_uri: "https://example.com/callback", owner: poc)
+
+ get "/api/v4/oauth/applications/#{application.id}"
+
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include(poc.email)
+ end
+
+ it "lists the point of contact" do
+ Doorkeeper::Application.create!(name: "Test App", redirect_uri: "https://example.com/callback", owner: poc)
+
+ get "/api/v4/oauth/applications"
+
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include(poc.email)
+ end
+end
diff --git a/spec/tasks/maintenance/revoke_locked_users_api_access_task_spec.rb b/spec/tasks/maintenance/revoke_locked_users_api_access_task_spec.rb
index 9bb003b980..4a5604e12f 100644
--- a/spec/tasks/maintenance/revoke_locked_users_api_access_task_spec.rb
+++ b/spec/tasks/maintenance/revoke_locked_users_api_access_task_spec.rb
@@ -14,7 +14,7 @@
end
it "revokes a locked user's expired tokens that still hold a refresh token" do
- application = Doorkeeper::Application.create!(name: "Test App", redirect_uri: "https://example.com/callback")
+ application = Doorkeeper::Application.create!(name: "Test App", redirect_uri: "https://example.com/callback", owner: create(:user))
survivor = create(:api_token, user: locked_user, application:, use_refresh_token: true, expires_in: 1, created_at: 3.hours.ago)
described_class.process(locked_user)