diff --git a/Gemfile b/Gemfile
index cc0c02bab..d0f0dd08c 100644
--- a/Gemfile
+++ b/Gemfile
@@ -183,3 +183,5 @@ gem "rswag", "~> 2.16"
gem "warning", "~> 1.5"
gem "rack-cors", "~> 2.0"
+
+gem "doorkeeper", "~> 5.8"
diff --git a/Gemfile.lock b/Gemfile.lock
index 32cc8d107..0631bc6d4 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -206,6 +206,8 @@ GEM
diff-lcs (1.5.1)
docile (1.4.0)
domain_name (0.6.20240107)
+ doorkeeper (5.8.2)
+ railties (>= 5)
dotenv (3.1.7)
dotenv-rails (3.1.7)
dotenv (= 3.1.7)
@@ -858,6 +860,7 @@ DEPENDENCIES
devise (~> 4.9)
devise-i18n (~> 1.13)
devise_zxcvbn (~> 6.0)
+ doorkeeper (~> 5.8)
dotenv-rails (~> 3.1)
erb_lint (~> 0.9.0)
factory_bot
diff --git a/app/api/v0/openapi.json b/app/api/v0/openapi.json
index 94eaf77fe..9d5dda57c 100644
--- a/app/api/v0/openapi.json
+++ b/app/api/v0/openapi.json
@@ -38,6 +38,13 @@
}
}
],
+ "security": [
+ {
+ "client_credentials": [
+ "read"
+ ]
+ }
+ ],
"responses": {
"200": {
"description": "Success",
@@ -151,6 +158,13 @@
}
}
],
+ "security": [
+ {
+ "client_credentials": [
+ "read"
+ ]
+ }
+ ],
"responses": {
"200": {
"description": "Success",
@@ -238,6 +252,13 @@
}
}
],
+ "security": [
+ {
+ "client_credentials": [
+ "read"
+ ]
+ }
+ ],
"responses": {
"200": {
"description": "Success",
@@ -351,6 +372,13 @@
}
}
],
+ "security": [
+ {
+ "client_credentials": [
+ "read"
+ ]
+ }
+ ],
"responses": {
"200": {
"description": "Success",
@@ -419,6 +447,13 @@
}
}
],
+ "security": [
+ {
+ "client_credentials": [
+ "read"
+ ]
+ }
+ ],
"responses": {
"200": {
"description": "Success",
@@ -556,6 +591,11 @@
}
}
],
+ "security": [
+ {
+ "client_credentials": []
+ }
+ ],
"responses": {
"200": {
"description": "Success",
@@ -673,6 +713,11 @@
}
}
],
+ "security": [
+ {
+ "client_credentials": []
+ }
+ ],
"responses": {
"200": {
"description": "Success",
@@ -1157,6 +1202,20 @@
}
],
"components": {
+ "securitySchemes": {
+ "client_credentials": {
+ "type": "oauth2",
+ "description": "Authentication with the OAuth2 Client Credentials grant flow. You can generate a client app and a long-lived bearer token at /oauth/applications.",
+ "flows": {
+ "clientCredentials": {
+ "tokenUrl": "/oauth/token",
+ "scopes": {
+ "read": "read any data accessible to the OAuth application's owner"
+ }
+ }
+ }
+ }
+ },
"schemas": {
"jsonld_context": {
"type": "array",
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 7568c2da5..e5f8793ee 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -6,6 +6,7 @@ class ApplicationController < ActionController::Base
after_action :set_content_security_policy_header, if: -> { request.format.html? }
before_action :authenticate_user!, unless: -> { SiteSettings.multiuser_enabled? || has_signed_id? }
+ before_action :doorkeeper_token_authorize!, if: :is_api_request?
around_action :switch_locale
before_action :check_for_first_use
before_action :show_security_alerts
@@ -104,6 +105,19 @@ def random_delay
private
+ def is_api_request?
+ request.format.json_ld?
+ end
+
+ def doorkeeper_token_authorize!
+ app_owner = doorkeeper_token&.application&.owner
+ if app_owner&.active_for_authentication?
+ sign_in app_owner, store: false
+ else
+ doorkeeper_render_error
+ end
+ end
+
def user_not_authorized
if current_user
raise ActiveRecord::RecordNotFound
diff --git a/app/controllers/doorkeeper_applications_controller.rb b/app/controllers/doorkeeper_applications_controller.rb
new file mode 100644
index 000000000..574cd756d
--- /dev/null
+++ b/app/controllers/doorkeeper_applications_controller.rb
@@ -0,0 +1,84 @@
+class DoorkeeperApplicationsController < ApplicationController
+ before_action :get_application, except: [:index, :new, :create]
+
+ def index
+ @applications = policy_scope(Doorkeeper::Application)
+ end
+
+ def show
+ get_access_token
+ end
+
+ def new
+ authorize Doorkeeper::Application
+ @application = Doorkeeper::Application.new(
+ redirect_uri: "urn:ietf:wg:oauth:2.0:oob",
+ scopes: Doorkeeper.configuration.default_scopes
+ )
+ end
+
+ def edit
+ end
+
+ def create
+ authorize Doorkeeper::Application
+ @application = Doorkeeper::Application.create(application_params.merge(owner: current_user))
+ if @application.valid?
+ redirect_to @application, notice: t(".success")
+ else
+ flash.now[:alert] = t(".failure")
+ render :new
+ end
+ end
+
+ def update
+ generate_token if application_params[:generate_token]
+ @application.update(application_params.except(:generate_token))
+ if @application.save
+ get_access_token
+ render :show, notice: t(".success")
+ else
+ flash.now[:alert] = t(".failure")
+ render :edit
+ end
+ end
+
+ def destroy
+ @application.destroy
+ redirect_to doorkeeper_applications_path, notice: t(".success")
+ end
+
+ private
+
+ def application_params
+ params.require(:doorkeeper_application).permit(
+ :name,
+ :redirect_uri,
+ :confidential,
+ :scopes,
+ :generate_token
+ )
+ end
+
+ def get_application
+ @application = policy_scope(Doorkeeper::Application).find(params[:id])
+ authorize @application
+ end
+
+ def generate_token
+ # Revoke existing tokens
+ Doorkeeper::Application.revoke_tokens_and_grants_for(@application, @application.owner)
+ # Create new access token
+ token = @application.access_tokens.create(
+ expires_in: 6.months,
+ resource_owner_id: @application.owner.id,
+ scopes: @application.scopes
+ )
+ # Make plaintext available to view
+ @plaintext_token = token.plaintext_token
+ end
+
+ def get_access_token
+ @access_token = @application.access_tokens.where(revoked_at: nil).first
+ end
+end
diff --git a/app/models/user.rb b/app/models/user.rb
index f9820bff7..f14a3a9d7 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -46,6 +46,22 @@ class User < ApplicationRecord
attribute :problem_settings, :json
attribute :file_list_settings, :json
+ has_many :access_grants, # rubocop:disable Rails/InverseOf
+ class_name: "Doorkeeper::AccessGrant",
+ foreign_key: :resource_owner_id,
+ dependent: :delete_all
+
+ has_many :access_tokens, # rubocop:disable Rails/InverseOf
+ class_name: "Doorkeeper::AccessToken",
+ foreign_key: :resource_owner_id,
+ dependent: :delete_all
+
+ has_many :oauth_applications,
+ class_name: "Doorkeeper::Application",
+ as: :owner,
+ dependent: :delete_all,
+ inverse_of: :owner
+
def federails_name
username
end
diff --git a/app/policies/doorkeeper/application_policy.rb b/app/policies/doorkeeper/application_policy.rb
new file mode 100644
index 000000000..e08d64403
--- /dev/null
+++ b/app/policies/doorkeeper/application_policy.rb
@@ -0,0 +1,48 @@
+class Doorkeeper::ApplicationPolicy < ApplicationPolicy
+ def index?
+ user.present?
+ end
+
+ def show?
+ one_of(
+ record.owner == user,
+ user&.is_moderator?
+ )
+ end
+
+ def create?
+ none_of(
+ SiteSettings.demo_mode_enabled?
+ )
+ end
+
+ def update?
+ all_of(
+ one_of(
+ user == record,
+ user&.is_administrator?
+ ),
+ SiteSettings.multiuser_enabled?,
+ none_of(
+ SiteSettings.demo_mode_enabled?
+ )
+ )
+ end
+
+ def destroy?
+ update?
+ end
+
+ class Scope
+ attr_reader :user, :scope
+
+ def initialize(user, scope)
+ @user = user
+ @scope = scope
+ end
+
+ def resolve
+ user.is_moderator? ? scope : scope.where(owner: user)
+ end
+ end
+end
diff --git a/app/views/doorkeeper_applications/_breadcrumb.html.erb b/app/views/doorkeeper_applications/_breadcrumb.html.erb
new file mode 100644
index 000000000..dd595f13d
--- /dev/null
+++ b/app/views/doorkeeper_applications/_breadcrumb.html.erb
@@ -0,0 +1,12 @@
+<% content_for :breadcrumbs do %>
+
+<% end %>
diff --git a/app/views/doorkeeper_applications/_form.html.erb b/app/views/doorkeeper_applications/_form.html.erb
new file mode 100644
index 000000000..15d96ddfb
--- /dev/null
+++ b/app/views/doorkeeper_applications/_form.html.erb
@@ -0,0 +1,7 @@
+<%= form_with model: @application do |form| %>
+ <%= text_input_row form, :name, autofocus: true %>
+ <%= text_input_row form, :redirect_uri, help: t(".redirect_uri.help") %>
+ <%= text_input_row form, :scopes, help: t(".scopes.help") %>
+ <%= checkbox_input_row form, :confidential, help: t(".confidential.help") %>
+ <%= form.submit translate(".submit"), class: "btn btn-primary" %>
+<% end %>
diff --git a/app/views/doorkeeper_applications/edit.html.erb b/app/views/doorkeeper_applications/edit.html.erb
new file mode 100644
index 000000000..038ea63aa
--- /dev/null
+++ b/app/views/doorkeeper_applications/edit.html.erb
@@ -0,0 +1,4 @@
+<%= render "breadcrumb" %>
+
<%= t(".title") %>
+
+<%= render "form" %>
diff --git a/app/views/doorkeeper_applications/index.html.erb b/app/views/doorkeeper_applications/index.html.erb
new file mode 100644
index 000000000..858920218
--- /dev/null
+++ b/app/views/doorkeeper_applications/index.html.erb
@@ -0,0 +1,22 @@
+<%= t(".title") %>
+
+<%= t(".description") %>
+
+
+
+ | <%= Doorkeeper::Application.human_attribute_name(:name) %> |
+ <%= content_tag(:td) { Doorkeeper::Application.human_attribute_name(:owner) } if current_user.is_moderator? %>
+ <%= t Doorkeeper::Application.human_attribute_name(:scopes) %> |
+ <%= t Doorkeeper::Application.human_attribute_name(:created_at) %> |
+
+ <% @applications.find_each do |app| %>
+
+ | <%= link_to app.name, app %> |
+ <%= content_tag(:td) { app.owner.username } if current_user.is_moderator? %>
+ <%= app.scopes %> |
+ <%= app.created_at.to_fs(:long) %> |
+
+ <% end %>
+
+
+<%= link_to t(".new"), new_doorkeeper_application_path, class: "btn btn-primary" %>
diff --git a/app/views/doorkeeper_applications/new.html.erb b/app/views/doorkeeper_applications/new.html.erb
new file mode 100644
index 000000000..038ea63aa
--- /dev/null
+++ b/app/views/doorkeeper_applications/new.html.erb
@@ -0,0 +1,4 @@
+<%= render "breadcrumb" %>
+<%= t(".title") %>
+
+<%= render "form" %>
diff --git a/app/views/doorkeeper_applications/show.html.erb b/app/views/doorkeeper_applications/show.html.erb
new file mode 100644
index 000000000..97ae48e39
--- /dev/null
+++ b/app/views/doorkeeper_applications/show.html.erb
@@ -0,0 +1,42 @@
+<%= render "breadcrumb" %>
+<%= t(".title") %>
+
+
+
+ | <%= Doorkeeper::Application.human_attribute_name :name %> |
+ <%= @application.name %> |
+
+
+ | <%= Doorkeeper::Application.human_attribute_name :uid %> |
+ <%= @application.uid %> |
+
+
+ | <%= Doorkeeper::Application.human_attribute_name :secret %> |
+ <%= @application.secret %> |
+
+
+ | <%= Doorkeeper::Application.human_attribute_name :confidential %> |
+ <%= @application.confidential %> |
+
+
+ | <%= Doorkeeper::Application.human_attribute_name :redirect_uri %> |
+ <%= @application.redirect_uri %> |
+
+
+ | <%= Doorkeeper::Application.human_attribute_name :access_token %> |
+
+ <% if @plaintext_token %>
+
+ <%= t(".plaintext_token.help") %>
+ <%= text_field :access_token, {}, value: @plaintext_token, readonly: true, class: "form-control" %>
+
+ <% else %>
+ <%= t(".access_token.expiry", how_long: time_ago_in_words(@access_token.expires_at)) if @access_token %>
+ <%= link_to t(".access_token.generate"), doorkeeper_application_path(@application, "doorkeeper_application[generate_token]": "1"), method: :patch, class: "ms-5 btn btn-sm btn-outline-warning" %>
+ <% end %>
+ |
+
+
+
+<%= link_to t(".edit"), edit_doorkeeper_application_path(@application), class: "btn btn-primary" %>
+<%= link_to t(".destroy"), doorkeeper_application_path(@application), method: :delete, class: "btn btn-outline-danger" %>
diff --git a/app/views/layouts/settings.html.erb b/app/views/layouts/settings.html.erb
index 00cbbff63..aef44d129 100644
--- a/app/views/layouts/settings.html.erb
+++ b/app/views/layouts/settings.html.erb
@@ -33,6 +33,9 @@
<%= link_to t("settings.reporting.heading"), reporting_settings_path, class: "nav-link" %>
+
+ <%= link_to t("doorkeeper_applications.index.title"), doorkeeper_applications_path, class: "nav-link" %>
+
<% unless SiteSettings.demo_mode_enabled? %>
diff --git a/config/initializers/doorkeeper.rb b/config/initializers/doorkeeper.rb
new file mode 100644
index 000000000..a5e0148e2
--- /dev/null
+++ b/config/initializers/doorkeeper.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+Doorkeeper.configure do
+ orm :active_record
+ base_controller "ActionController::Base"
+
+ # Enabled grant flows
+ grant_flows %w[client_credentials]
+
+ # Security
+ hash_token_secrets
+ authorization_code_expires_in 10.minutes
+ access_token_expires_in 2.hours
+ force_ssl_in_redirect_uri Manyfold::Application.config.force_ssl
+ forbid_redirect_uri { |uri| uri.scheme.to_s.downcase == "javascript" }
+
+ # Authentication
+ resource_owner_from_credentials { nil }
+ admin_authenticator { current_user } # Temporary
+
+ # Available scopes
+ default_scopes :read
+ enforce_configured_scopes
+
+ # Per-user applications
+ enable_application_owner confirmation: true
+end
diff --git a/config/locales/doorkeeper.en.yml b/config/locales/doorkeeper.en.yml
new file mode 100644
index 000000000..c9190526f
--- /dev/null
+++ b/config/locales/doorkeeper.en.yml
@@ -0,0 +1,155 @@
+en:
+ activerecord:
+ attributes:
+ doorkeeper/application:
+ name: 'Name'
+ redirect_uri: 'Redirect URI'
+ errors:
+ models:
+ doorkeeper/application:
+ attributes:
+ redirect_uri:
+ fragment_present: 'cannot contain a fragment.'
+ invalid_uri: 'must be a valid URI.'
+ unspecified_scheme: 'must specify a scheme.'
+ relative_uri: 'must be an absolute URI.'
+ secured_uri: 'must be an HTTPS/SSL URI.'
+ forbidden_uri: 'is forbidden by the server.'
+ scopes:
+ not_match_configured: "doesn't match configured on the server."
+
+ doorkeeper:
+ applications:
+ confirmations:
+ destroy: 'Are you sure?'
+ buttons:
+ edit: 'Edit'
+ destroy: 'Destroy'
+ submit: 'Submit'
+ cancel: 'Cancel'
+ authorize: 'Authorize'
+ form:
+ error: 'Whoops! Check your form for possible errors'
+ help:
+ confidential: 'Application will be used where the client secret can be kept confidential. Native mobile apps and Single Page Apps are considered non-confidential.'
+ redirect_uri: 'Use one line per URI'
+ blank_redirect_uri: "Leave it blank if you configured your provider to use Client Credentials, Resource Owner Password Credentials or any other grant type that doesn't require redirect URI."
+ scopes: 'Separate scopes with spaces. Leave blank to use the default scopes.'
+ edit:
+ title: 'Edit application'
+ index:
+ title: 'Your applications'
+ new: 'New Application'
+ name: 'Name'
+ callback_url: 'Callback URL'
+ confidential: 'Confidential?'
+ actions: 'Actions'
+ confidentiality:
+ 'yes': 'Yes'
+ 'no': 'No'
+ new:
+ title: 'New Application'
+ show:
+ title: 'Application: %{name}'
+ application_id: 'UID'
+ secret: 'Secret'
+ secret_hashed: 'Secret hashed'
+ scopes: 'Scopes'
+ confidential: 'Confidential'
+ callback_urls: 'Callback urls'
+ actions: 'Actions'
+ not_defined: 'Not defined'
+
+ authorizations:
+ buttons:
+ authorize: 'Authorize'
+ deny: 'Deny'
+ error:
+ title: 'An error has occurred'
+ new:
+ title: 'Authorization required'
+ prompt: 'Authorize %{client_name} to use your account?'
+ able_to: 'This application will be able to'
+ show:
+ title: 'Authorization code'
+ form_post:
+ title: 'Submit this form'
+
+ authorized_applications:
+ confirmations:
+ revoke: 'Are you sure?'
+ buttons:
+ revoke: 'Revoke'
+ index:
+ title: 'Your authorized applications'
+ application: 'Application'
+ created_at: 'Created At'
+ date_format: '%Y-%m-%d %H:%M:%S'
+
+ pre_authorization:
+ status: 'Pre-authorization'
+
+ errors:
+ messages:
+ # Common error messages
+ invalid_request:
+ unknown: 'The request is missing a required parameter, includes an unsupported parameter value, or is otherwise malformed.'
+ missing_param: 'Missing required parameter: %{value}.'
+ request_not_authorized: 'Request need to be authorized. Required parameter for authorizing request is missing or invalid.'
+ invalid_code_challenge: 'Code challenge is required.'
+ invalid_redirect_uri: "The requested redirect uri is malformed or doesn't match client redirect URI."
+ unauthorized_client: 'The client is not authorized to perform this request using this method.'
+ access_denied: 'The resource owner or authorization server denied the request.'
+ invalid_scope: 'The requested scope is invalid, unknown, or malformed.'
+ invalid_code_challenge_method:
+ zero: 'The authorization server does not support PKCE as there are no accepted code_challenge_method values.'
+ one: 'The code_challenge_method must be %{challenge_methods}.'
+ other: 'The code_challenge_method must be one of %{challenge_methods}.'
+ server_error: 'The authorization server encountered an unexpected condition which prevented it from fulfilling the request.'
+ temporarily_unavailable: 'The authorization server is currently unable to handle the request due to a temporary overloading or maintenance of the server.'
+
+ # Configuration error messages
+ credential_flow_not_configured: 'Resource Owner Password Credentials flow failed due to Doorkeeper.configure.resource_owner_from_credentials being unconfigured.'
+ resource_owner_authenticator_not_configured: 'Resource Owner find failed due to Doorkeeper.configure.resource_owner_authenticator being unconfigured.'
+ admin_authenticator_not_configured: 'Access to admin panel is forbidden due to Doorkeeper.configure.admin_authenticator being unconfigured.'
+
+ # Access grant errors
+ unsupported_response_type: 'The authorization server does not support this response type.'
+ unsupported_response_mode: 'The authorization server does not support this response mode.'
+
+ # Access token errors
+ invalid_client: 'Client authentication failed due to unknown client, no client authentication included, or unsupported authentication method.'
+ invalid_grant: 'The provided authorization grant is invalid, expired, revoked, does not match the redirection URI used in the authorization request, or was issued to another client.'
+ unsupported_grant_type: 'The authorization grant type is not supported by the authorization server.'
+
+ invalid_token:
+ revoked: "The access token was revoked"
+ expired: "The access token expired"
+ unknown: "The access token is invalid"
+ revoke:
+ unauthorized: "You are not authorized to revoke this token"
+
+ forbidden_token:
+ missing_scope: 'Access to this resource requires scope "%{oauth_scopes}".'
+
+ flash:
+ applications:
+ create:
+ notice: 'Application created.'
+ destroy:
+ notice: 'Application deleted.'
+ update:
+ notice: 'Application updated.'
+ authorized_applications:
+ destroy:
+ notice: 'Application revoked.'
+
+ layouts:
+ admin:
+ title: 'Doorkeeper'
+ nav:
+ oauth2_provider: 'OAuth2 Provider'
+ applications: 'Applications'
+ home: 'Home'
+ application:
+ title: 'OAuth authorization required'
diff --git a/config/locales/en.yml b/config/locales/en.yml
index e941b63d9..2b8af2b9f 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -12,6 +12,16 @@ en:
name: Name
notes: Description
slug: Handle
+ doorkeeper/application:
+ access_token: Access Token
+ confidential: Confidential
+ created_at: Created
+ name: Name
+ owner: Owner
+ redirect_uri: Redirect URI
+ scopes: Scopes
+ secret: Client Secret
+ uid: Client ID
federails/moderation/domain_block:
created_at: Created at
domain: Domain
@@ -283,6 +293,40 @@ en:
tags_card:
skip_tags: Skip tag list
title: Manyfold
+ doorkeeper_applications:
+ create:
+ failure: An error occurred, and the application could not be created.
+ success: Application created successfully.
+ destroy:
+ success: Application deleted successfully.
+ edit:
+ title: Edit application
+ form:
+ confidential:
+ help: A confidential application can hold secrets securely (e.g. a web server backend, or machine-to-machine script).
+ redirect_uri:
+ help: Use "urn:ietf:wg:oauth:2.0:oob" if your application does not need a redirect URI (e.g. machine-to-machine apps).
+ scopes:
+ help: Currently only "read" scope is available. More are coming soon.
+ submit: Save application
+ index:
+ description: OAuth applications allow you to access Manyfold resources from other services via our API.
+ new: New application
+ title: OAuth Applications
+ new:
+ title: New application
+ show:
+ access_token:
+ expiry: Expires in %{how_long}
+ generate: Create new access token
+ destroy: Delete
+ edit: Edit
+ plaintext_token:
+ help: This token will only be shown once. If you lose it, you will need to generate a new one, which will automatically revoke all existing tokens.
+ title: Application details
+ update:
+ failure: An error occurred, and the application could not be saved.
+ success: Application saved successfully.
errors:
messages:
already_confirmed: was already confirmed, please try signing in
diff --git a/config/routes.rb b/config/routes.rb
index a039583ae..d067ba1a7 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -140,4 +140,9 @@
mount Rswag::Ui::Engine => "/api", :as => :api
mount Rswag::Api::Engine => "/api"
+
+ use_doorkeeper do
+ skip_controllers :applications
+ end
+ resources :doorkeeper_applications, path: "/oauth/applications"
end
diff --git a/db/migrate/20250408111644_create_doorkeeper_tables.rb b/db/migrate/20250408111644_create_doorkeeper_tables.rb
new file mode 100644
index 000000000..e9fba4fcd
--- /dev/null
+++ b/db/migrate/20250408111644_create_doorkeeper_tables.rb
@@ -0,0 +1,98 @@
+# frozen_string_literal: true
+
+class CreateDoorkeeperTables < ActiveRecord::Migration[7.2]
+ def change
+ create_table :oauth_applications do |t|
+ t.string :name, null: false
+ t.string :uid, null: false
+ t.string :secret, null: false
+ t.text :redirect_uri
+ 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
+
+ create_table :oauth_access_grants do |t|
+ t.references :resource_owner, null: false
+ t.references :application, null: false
+ t.string :token, null: false
+ t.integer :expires_in, null: false
+ t.text :redirect_uri, null: false
+ t.string :scopes, null: false, default: ""
+ t.datetime :created_at, null: false
+ t.datetime :revoked_at
+ end
+
+ add_index :oauth_access_grants, :token, unique: true
+ add_foreign_key(
+ :oauth_access_grants,
+ :oauth_applications,
+ column: :application_id
+ )
+
+ create_table :oauth_access_tokens do |t|
+ t.references :resource_owner, index: true
+
+ # Remove `null: false` if you are planning to use Password
+ # Credentials Grant flow that doesn't require an application.
+ t.references :application, null: false
+
+ # If you use a custom token generator you may need to change this column
+ # from string to text, so that it accepts tokens larger than 255
+ # characters. More info on custom token generators in:
+ # https://github.com/doorkeeper-gem/doorkeeper/tree/v3.0.0.rc1#custom-access-token-generator
+ #
+ # t.text :token, null: false
+ t.string :token, null: false
+
+ t.string :refresh_token
+ t.integer :expires_in
+ t.string :scopes
+ t.datetime :created_at, null: false
+ t.datetime :revoked_at
+
+ # The authorization server MAY issue a new refresh token, in which case
+ # *the client MUST discard the old refresh token* and replace it with the
+ # new refresh token. The authorization server MAY revoke the old
+ # refresh token after issuing a new refresh token to the client.
+ # @see https://datatracker.ietf.org/doc/html/rfc6749#section-6
+ #
+ # Doorkeeper implementation: if there is a `previous_refresh_token` column,
+ # refresh tokens will be revoked after a related access token is used.
+ # If there is no `previous_refresh_token` column, previous tokens are
+ # revoked as soon as a new access token is created.
+ #
+ # Comment out this line if you want refresh tokens to be instantly
+ # revoked after use.
+ t.string :previous_refresh_token, null: false, default: ""
+ end
+
+ add_index :oauth_access_tokens, :token, unique: true
+
+ reversible do |dir|
+ dir.up do
+ # See https://github.com/doorkeeper-gem/doorkeeper/issues/1592
+ if ActiveRecord::Base.connection.adapter_name == "SQLServer"
+ execute <<~SQL.squish
+ CREATE UNIQUE NONCLUSTERED INDEX index_oauth_access_tokens_on_refresh_token ON oauth_access_tokens(refresh_token)
+ WHERE refresh_token IS NOT NULL
+ SQL
+ else
+ add_index :oauth_access_tokens, :refresh_token, unique: true
+ end
+ end
+ end
+
+ add_foreign_key(
+ :oauth_access_tokens,
+ :oauth_applications,
+ column: :application_id
+ )
+
+ # Uncomment below to ensure a valid reference to the resource owner's table
+ add_foreign_key :oauth_access_grants, :users, column: :resource_owner_id
+ add_foreign_key :oauth_access_tokens, :users, column: :resource_owner_id
+ end
+end
diff --git a/db/migrate/20250408145956_add_owner_to_application.rb b/db/migrate/20250408145956_add_owner_to_application.rb
new file mode 100644
index 000000000..897ee0b60
--- /dev/null
+++ b/db/migrate/20250408145956_add_owner_to_application.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class AddOwnerToApplication < ActiveRecord::Migration[7.2]
+ def change
+ add_column :oauth_applications, :owner_id, :bigint, null: true
+ add_column :oauth_applications, :owner_type, :string, null: true
+ add_index :oauth_applications, [:owner_id, :owner_type]
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index c1ac4e15c..d8fe4dc34 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[7.2].define(version: 2025_04_07_160722) do
+ActiveRecord::Schema[7.2].define(version: 2025_04_08_145956) do
create_table "caber_relations", force: :cascade do |t|
t.string "subject_type"
t.integer "subject_id"
@@ -262,6 +262,51 @@
t.index ["slug"], name: "index_models_on_slug"
end
+ create_table "oauth_access_grants", force: :cascade do |t|
+ t.integer "resource_owner_id", null: false
+ t.integer "application_id", null: false
+ t.string "token", null: false
+ t.integer "expires_in", null: false
+ t.text "redirect_uri", null: false
+ t.string "scopes", default: "", null: false
+ t.datetime "created_at", null: false
+ t.datetime "revoked_at"
+ t.index ["application_id"], name: "index_oauth_access_grants_on_application_id"
+ t.index ["resource_owner_id"], name: "index_oauth_access_grants_on_resource_owner_id"
+ t.index ["token"], name: "index_oauth_access_grants_on_token", unique: true
+ end
+
+ create_table "oauth_access_tokens", force: :cascade do |t|
+ t.integer "resource_owner_id"
+ t.integer "application_id", null: false
+ t.string "token", null: false
+ t.string "refresh_token"
+ t.integer "expires_in"
+ t.string "scopes"
+ t.datetime "created_at", null: false
+ t.datetime "revoked_at"
+ t.string "previous_refresh_token", default: "", null: false
+ t.index ["application_id"], name: "index_oauth_access_tokens_on_application_id"
+ t.index ["refresh_token"], name: "index_oauth_access_tokens_on_refresh_token", unique: true
+ t.index ["resource_owner_id"], name: "index_oauth_access_tokens_on_resource_owner_id"
+ t.index ["token"], name: "index_oauth_access_tokens_on_token", unique: true
+ end
+
+ create_table "oauth_applications", force: :cascade do |t|
+ t.string "name", null: false
+ t.string "uid", null: false
+ t.string "secret", null: false
+ t.text "redirect_uri"
+ t.string "scopes", default: "", null: false
+ t.boolean "confidential", default: true, null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.bigint "owner_id"
+ t.string "owner_type"
+ t.index ["owner_id", "owner_type"], name: "index_oauth_applications_on_owner_id_and_owner_type"
+ t.index ["uid"], name: "index_oauth_applications_on_uid", unique: true
+ end
+
create_table "problems", force: :cascade do |t|
t.string "problematic_type"
t.integer "problematic_id"
@@ -367,5 +412,9 @@
add_foreign_key "models", "collections"
add_foreign_key "models", "creators"
add_foreign_key "models", "libraries"
+ add_foreign_key "oauth_access_grants", "oauth_applications", column: "application_id"
+ add_foreign_key "oauth_access_grants", "users", column: "resource_owner_id"
+ add_foreign_key "oauth_access_tokens", "oauth_applications", column: "application_id"
+ add_foreign_key "oauth_access_tokens", "users", column: "resource_owner_id"
add_foreign_key "taggings", "tags"
end
diff --git a/spec/factories/doorkeeper/access_token.rb b/spec/factories/doorkeeper/access_token.rb
new file mode 100644
index 000000000..521354a29
--- /dev/null
+++ b/spec/factories/doorkeeper/access_token.rb
@@ -0,0 +1,5 @@
+FactoryBot.define do
+ factory :oauth_access_token, class: "Doorkeeper::AccessToken" do
+ application { association :oauth_application }
+ end
+end
diff --git a/spec/factories/doorkeeper/application.rb b/spec/factories/doorkeeper/application.rb
new file mode 100644
index 000000000..bf590a880
--- /dev/null
+++ b/spec/factories/doorkeeper/application.rb
@@ -0,0 +1,9 @@
+FactoryBot.define do
+ factory :oauth_application, class: "Doorkeeper::Application" do
+ name { Faker::Appliance.equipment }
+ redirect_uri { "urn:ietf:wg:oauth:2.0:oob" }
+ scopes { "read" }
+ confidential { true }
+ owner { association :moderator }
+ end
+end
diff --git a/spec/requests/api/json_ld/collections_spec.rb b/spec/requests/api/json_ld/collections_spec.rb
index c1fc64b3c..20bbff342 100644
--- a/spec/requests/api/json_ld/collections_spec.rb
+++ b/spec/requests/api/json_ld/collections_spec.rb
@@ -13,6 +13,7 @@
produces "application/ld+json"
parameter name: :page, in: :query, type: :integer, example: 1, description: "Specify which page of results to retrieve.", required: false
parameter name: :order, in: :query, type: :string, enum: ["name", "recent"], description: "Specify order of results; either by name or creation time", example: "name", required: false
+ security [client_credentials: ["read"]]
response "200", "Success" do
schema type: :object,
@@ -47,6 +48,7 @@
},
required: ["@context", "@id", "@type", "totalItems", "member", "view"]
+ let(:Authorization) { "Bearer #{create(:oauth_access_token).plaintext_token}" } # rubocop:disable RSpec/VariableName
run_test!
end
end
@@ -57,6 +59,7 @@
tags "Collections"
produces "application/ld+json"
parameter name: :id, in: :path, type: :string, required: true, example: "abc123"
+ security [client_credentials: ["read"]]
response "200", "Success" do
schema type: :object,
@@ -76,6 +79,7 @@
},
required: ["@context", "@id", "@type", "name"]
+ let(:Authorization) { "Bearer #{create(:oauth_access_token).plaintext_token}" } # rubocop:disable RSpec/VariableName
let(:id) { Collection.first.to_param }
run_test!
end
diff --git a/spec/requests/api/json_ld/creators_spec.rb b/spec/requests/api/json_ld/creators_spec.rb
index 52865572e..95fa151c8 100644
--- a/spec/requests/api/json_ld/creators_spec.rb
+++ b/spec/requests/api/json_ld/creators_spec.rb
@@ -13,6 +13,7 @@
produces "application/ld+json"
parameter name: :page, in: :query, type: :integer, example: 1, description: "Specify which page of results to retrieve.", required: false
parameter name: :order, in: :query, type: :string, enum: ["name", "recent"], description: "Specify order of results; either by name or creation time", example: "name", required: false
+ security [client_credentials: ["read"]]
response "200", "Success" do
schema type: :object,
@@ -47,6 +48,7 @@
},
required: ["@context", "@id", "@type", "totalItems", "member", "view"]
+ let(:Authorization) { "Bearer #{create(:oauth_access_token).plaintext_token}" } # rubocop:disable RSpec/VariableName
run_test!
end
end
@@ -57,6 +59,7 @@
tags "Creators"
produces "application/ld+json"
parameter name: :id, in: :path, type: :string, required: true, example: "abc123"
+ security [client_credentials: ["read"]]
response "200", "Success" do
schema type: :object,
@@ -69,6 +72,7 @@
},
required: ["@context", "@id", "@type", "name"]
+ let(:Authorization) { "Bearer #{create(:oauth_access_token).plaintext_token}" } # rubocop:disable RSpec/VariableName
let(:id) { Creator.first.to_param }
run_test!
end
diff --git a/spec/requests/api/json_ld/model_files_spec.rb b/spec/requests/api/json_ld/model_files_spec.rb
index 3c29fb7d4..2acaecbd4 100644
--- a/spec/requests/api/json_ld/model_files_spec.rb
+++ b/spec/requests/api/json_ld/model_files_spec.rb
@@ -14,6 +14,7 @@
produces "application/ld+json"
parameter name: :model_id, in: :path, type: :string, required: true, example: "abc123"
parameter name: :id, in: :path, type: :string, required: true, example: "def456"
+ security [client_credentials: ["read"]]
response "200", "Success" do
schema type: :object,
@@ -41,6 +42,7 @@
},
required: ["@context", "@id", "@type", "name", "isPartOf", "encodingFormat"]
+ let(:Authorization) { "Bearer #{create(:oauth_access_token).plaintext_token}" } # rubocop:disable RSpec/VariableName
let(:model_id) { Model.first.to_param }
let(:id) { ModelFile.first.to_param }
run_test!
diff --git a/spec/requests/api/json_ld/models_spec.rb b/spec/requests/api/json_ld/models_spec.rb
index 4757f2af1..b32e1810e 100644
--- a/spec/requests/api/json_ld/models_spec.rb
+++ b/spec/requests/api/json_ld/models_spec.rb
@@ -15,6 +15,7 @@
parameter name: :order, in: :query, type: :string, enum: ["name", "recent"], description: "Specify order of results; either by name or creation time", example: "name", required: false
parameter name: :creator, in: :query, type: :string, description: "The ID of a creator to filter the model list", example: "abc123", required: false
parameter name: :collection, in: :query, type: :string, description: "The ID of a collection to filter the model list", example: "abc123", required: false
+ security [client_credentials: []]
response "200", "Success" do
schema type: :object,
@@ -50,6 +51,7 @@
},
required: ["@context", "@id", "@type", "totalItems", "member", "view"]
+ let(:Authorization) { "Bearer #{create(:oauth_access_token).plaintext_token}" } # rubocop:disable RSpec/VariableName
run_test!
end
end
@@ -59,6 +61,7 @@
tags "Models"
produces "application/ld+json"
parameter name: :id, in: :path, type: :string, required: true, example: "abc123"
+ security [client_credentials: []]
response "200", "Success" do
schema type: :object,
@@ -103,6 +106,7 @@
},
required: ["@context", "@id", "@type", "name", "hasPart"]
+ let(:Authorization) { "Bearer #{create(:oauth_access_token).plaintext_token}" } # rubocop:disable RSpec/VariableName
let(:id) { Model.first.to_param }
run_test!
end
diff --git a/spec/swagger_helper.rb b/spec/swagger_helper.rb
index 53c68b1b4..8d9aac82b 100644
--- a/spec/swagger_helper.rb
+++ b/spec/swagger_helper.rb
@@ -37,6 +37,20 @@
}
],
components: {
+ securitySchemes: {
+ client_credentials: {
+ type: :oauth2,
+ description: "Authentication with the OAuth2 Client Credentials grant flow. You can generate a client app and a long-lived bearer token at /oauth/applications.",
+ flows: {
+ clientCredentials: {
+ tokenUrl: "/oauth/token",
+ scopes: {
+ read: "read any data accessible to the OAuth application's owner"
+ }
+ }
+ }
+ }
+ },
schemas: {
jsonld_context: {
type: :array,