Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions app/controllers/oauth_applications_controller.rb
Original file line number Diff line number Diff line change
@@ -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
70 changes: 70 additions & 0 deletions app/views/doorkeeper/applications/_form.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<%= form_for application, url: doorkeeper_submit_path(application), as: :doorkeeper_application, html: { role: "form" } do |f| %>
<% if application.errors.any? %>
<div class="alert alert-danger" data-alert><p><%= t("doorkeeper.applications.form.error") %></p></div>
<% end %>

<div class="form-group row">
<%= f.label :name, class: "col-sm-2 col-form-label font-weight-bold" %>
<div class="col-sm-10">
<%= f.text_field :name, class: "form-control #{ 'is-invalid' if application.errors[:name].present? }", required: true %>
<%= doorkeeper_errors_for application, :name %>
</div>
</div>

<div class="form-group row">
<%= f.label :owner_email, "Point of contact", class: "col-sm-2 col-form-label font-weight-bold" %>
<div class="col-sm-10">
<%= f.email_field :owner_email, value: params.dig(:doorkeeper_application, :owner_email) || application.owner&.email, class: "form-control #{ 'is-invalid' if application.errors[:owner].present? }", required: true %>
<%= doorkeeper_errors_for application, :owner %>
<span class="form-text text-secondary">
Email of the HCB user responsible for this application.
</span>
</div>
</div>

<div class="form-group row">
<%= f.label :redirect_uri, class: "col-sm-2 col-form-label font-weight-bold" %>
<div class="col-sm-10">
<%= f.text_area :redirect_uri, class: "form-control #{ 'is-invalid' if application.errors[:redirect_uri].present? }" %>
<%= doorkeeper_errors_for application, :redirect_uri %>
<span class="form-text text-secondary">
<%= t("doorkeeper.applications.help.redirect_uri") %>
</span>

<% if Doorkeeper.configuration.allow_blank_redirect_uri?(application) %>
<span class="form-text text-secondary">
<%= t("doorkeeper.applications.help.blank_redirect_uri") %>
</span>
<% end %>
</div>
</div>

<div class="form-group row">
<%= f.label :confidential, class: "col-sm-2 form-check-label font-weight-bold" %>
<div class="col-sm-10">
<%= f.check_box :confidential, class: "checkbox #{ 'is-invalid' if application.errors[:confidential].present? }" %>
<%= doorkeeper_errors_for application, :confidential %>
<span class="form-text text-secondary">
<%= t("doorkeeper.applications.help.confidential") %>
</span>
</div>
</div>

<div class="form-group row">
<%= f.label :scopes, class: "col-sm-2 col-form-label font-weight-bold" %>
<div class="col-sm-10">
<%= f.text_field :scopes, class: "form-control #{ 'has-error' if application.errors[:scopes].present? }" %>
<%= doorkeeper_errors_for application, :scopes %>
<span class="form-text text-secondary">
<%= t("doorkeeper.applications.help.scopes") %>
</span>
</div>
</div>

<div class="form-group">
<div class="col-sm-offset-2 col-sm-10">
<%= f.submit t("doorkeeper.applications.buttons.submit"), class: "btn btn-primary" %>
<%= link_to t("doorkeeper.applications.buttons.cancel"), oauth_applications_path, class: "btn btn-secondary" %>
</div>
</div>
<% end %>
46 changes: 46 additions & 0 deletions app/views/doorkeeper/applications/index.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<div class="border-bottom mb-4">
<h1><%= t(".title") %></h1>
</div>

<p><%= link_to t(".new"), new_oauth_application_path, class: "btn btn-success" %></p>

<table class="table table-striped">
<thead>
<tr>
<th><%= t(".name") %></th>
<th>Point of contact</th>
<th><%= t(".callback_url") %></th>
<th><%= t(".confidential") %></th>
<th><%= t(".actions") %></th>
<th></th>
</tr>
</thead>
<tbody>
<% @applications.each do |application| %>
<tr id="application_<%= application.id %>">
<td class="align-middle">
<%= link_to application.name, oauth_application_path(application) %>
</td>
<td class="align-middle">
<% if application.owner %>
<%= link_to application.owner.name, edit_user_path(application.owner) %> (<%= application.owner.email %>)
<% else %>
<span class="font-italic text-muted">Unassigned</span>
<% end %>
</td>
<td class="align-middle">
<%= simple_format(application.redirect_uri) %>
</td>
<td class="align-middle">
<%= application.confidential? ? t("doorkeeper.applications.index.confidentiality.yes") : t("doorkeeper.applications.index.confidentiality.no") %>
</td>
<td class="align-middle">
<%= link_to t("doorkeeper.applications.buttons.edit"), edit_oauth_application_path(application), class: "btn btn-link" %>
</td>
<td class="align-middle">
<%= render "doorkeeper/applications/delete_form", application: application %>
</td>
</tr>
<% end %>
</tbody>
</table>
72 changes: 72 additions & 0 deletions app/views/doorkeeper/applications/show.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<div class="border-bottom mb-4">
<h1><%= t(".title", name: @application.name) %></h1>
</div>

<div class="row">
<div class="col-md-8">
<h4>Point of contact:</h4>
<p>
<% if @application.owner %>
<%= link_to @application.owner.name, edit_user_path(@application.owner) %> (<%= @application.owner.email %>)
<% else %>
<span class="bg-light font-italic text-uppercase text-muted">Unassigned</span>
<% end %>
</p>

<h4><%= t(".application_id") %>:</h4>
<p><code class="bg-light" id="application_id"><%= @application.uid %></code></p>

<h4><%= t(".secret") %>:</h4>
<p>
<code class="bg-light" id="secret">
<% secret = flash[:application_secret].presence || @application.plaintext_secret %>
<% if secret.blank? && Doorkeeper.config.application_secret_hashed? %>
<span class="bg-light font-italic text-uppercase text-muted"><%= t(".secret_hashed") %></span>
<% else %>
<%= secret %>
<% end %>
</code>
</p>

<h4><%= t(".scopes") %>:</h4>
<p>
<code class="bg-light" id="scopes">
<% if @application.scopes.present? %>
<%= @application.scopes %>
<% else %>
<span class="bg-light font-italic text-uppercase text-muted"><%= t(".not_defined") %></span>
<% end %>
</code>
</p>

<h4><%= t(".confidential") %>:</h4>
<p><code class="bg-light" id="confidential"><%= @application.confidential? %></code></p>

<h4><%= t(".callback_urls") %>:</h4>

<% if @application.redirect_uri.present? %>
<table>
<% @application.redirect_uri.split.each do |uri| %>
<tr>
<td>
<code class="bg-light"><%= uri %></code>
</td>
<td>
<%= 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" %>
</td>
</tr>
<% end %>
</table>
<% else %>
<span class="bg-light font-italic text-uppercase text-muted"><%= t(".not_defined") %></span>
<% end %>
</div>

<div class="col-md-4">
<h3><%= t(".actions") %></h3>

<p><%= link_to t("doorkeeper.applications.buttons.edit"), edit_oauth_application_path(@application), class: "btn btn-primary" %></p>

<p><%= render "doorkeeper/applications/delete_form", application: @application, submit_btn_css: "btn btn-danger" %></p>
</div>
</div>
2 changes: 2 additions & 0 deletions config/initializers/doorkeeper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions config/locales/doorkeeper.en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.'
Expand Down
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
10 changes: 10 additions & 0 deletions db/migrate/20261102120000_add_owner_to_oauth_applications.rb
Original file line number Diff line number Diff line change
@@ -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
5 changes: 4 additions & 1 deletion db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion dev-docs/v4-api/standards.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
```

Expand Down
2 changes: 1 addition & 1 deletion spec/controllers/api/v4/donations_controller_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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}"

Expand Down
2 changes: 1 addition & 1 deletion spec/models/user_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion spec/requests/api/v4/oauth_locked_user_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion spec/requests/doorkeeper_impersonation_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
61 changes: 61 additions & 0 deletions spec/requests/oauth_applications_spec.rb
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading