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") %>

+ + + + + <%= content_tag(:td) { Doorkeeper::Application.human_attribute_name(:owner) } if current_user.is_moderator? %> + + + + <% @applications.find_each do |app| %> + + + <%= content_tag(:td) { app.owner.username } if current_user.is_moderator? %> + + + + <% end %> +
<%= Doorkeeper::Application.human_attribute_name(:name) %><%= t Doorkeeper::Application.human_attribute_name(:scopes) %><%= t Doorkeeper::Application.human_attribute_name(:created_at) %>
<%= link_to app.name, app %><%= app.scopes %><%= app.created_at.to_fs(:long) %>
+ +<%= 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 @@ + <% 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,