From a5aac60c6d09a2851a517eaf512a36356ae744e8 Mon Sep 17 00:00:00 2001 From: Judith Roth Date: Thu, 8 Oct 2026 22:11:55 +0200 Subject: [PATCH 1/9] [SE-664] Add API endpoint to create collaboration tokens for documents https://community.openproject.org/wp/SE-664 - POST /api/v3/documents/:id/collaboration_token creates an encrypted token for the collaboration server (Hocuspocus). - An optional `token` revokes the previous token 30 seconds later; each token can be revoked only once (race-free). - Requests authenticated with a collaboration token (an OAuth token issued to the Documents OAuth application) are rejected with 403. - Includes the OpenAPI docs. Co-Authored-By: Claude Opus 5.5 --- .../schemas/collaboration_token_model.yml | 83 +++++ .../components/schemas/document_model.yml | 15 + docs/api/apiv3/openapi-spec.yml | 4 + docs/api/apiv3/paths/document.yml | 6 + .../paths/document_collaboration_token.yml | 146 ++++++++ docs/api/apiv3/tags/documents.yml | 29 +- .../documents/refresh_tokens_controller.rb | 3 +- .../app/controllers/documents_controller.rb | 4 +- modules/documents/app/models/document.rb | 4 + .../documents/oauth/decrypt_token_service.rb | 68 ++++ .../documents/oauth/encrypt_token_service.rb | 18 +- .../oauth/ensure_application_service.rb | 7 + .../documents/oauth/token_encryptor.rb | 62 ++++ .../oauth/token_with_metadata_service.rb | 132 +++++++- modules/documents/config/locales/en.yml | 6 + .../v3/documents/collaboration_token_api.rb | 85 +++++ .../collaboration_token_representer.rb | 84 +++++ .../api/v3/documents/document_representer.rb | 11 + .../lib/api/v3/documents/documents_api.rb | 3 +- .../lib/open_project/documents/engine.rb | 4 + .../document_representer_rendering_spec.rb | 85 +++++ .../spec/api/v3/utilities/path_helper_spec.rb | 6 + .../refresh_tokens_controller_spec.rb | 20 +- .../documents/spec/models/document_spec.rb | 24 ++ .../collaboration_token_resource_spec.rb | 316 ++++++++++++++++++ .../oauth/decrypt_token_service_spec.rb | 104 ++++++ .../oauth/ensure_application_service_spec.rb | 24 ++ .../oauth/token_with_metadata_service_spec.rb | 287 +++++++++++++++- .../shared/collaboration_token_endpoint.rb | 211 ++++++++++++ 29 files changed, 1812 insertions(+), 39 deletions(-) create mode 100644 docs/api/apiv3/components/schemas/collaboration_token_model.yml create mode 100644 docs/api/apiv3/paths/document_collaboration_token.yml create mode 100644 modules/documents/app/services/documents/oauth/decrypt_token_service.rb create mode 100644 modules/documents/app/services/documents/oauth/token_encryptor.rb create mode 100644 modules/documents/lib/api/v3/documents/collaboration_token_api.rb create mode 100644 modules/documents/lib/api/v3/documents/collaboration_token_representer.rb create mode 100644 modules/documents/spec/requests/api/v3/documents/collaboration_token_resource_spec.rb create mode 100644 modules/documents/spec/services/documents/oauth/decrypt_token_service_spec.rb create mode 100644 modules/documents/spec/support/shared/collaboration_token_endpoint.rb diff --git a/docs/api/apiv3/components/schemas/collaboration_token_model.yml b/docs/api/apiv3/components/schemas/collaboration_token_model.yml new file mode 100644 index 000000000000..5b2def907574 --- /dev/null +++ b/docs/api/apiv3/components/schemas/collaboration_token_model.yml @@ -0,0 +1,83 @@ +# Schema: CollaborationTokenModel +--- +type: object +description: |- + A short-lived token granting access to a document on the real-time collaboration (Hocuspocus) server. + + The token is opaque: it is encrypted and can only be read by the collaboration server. + It must neither be stored nor logged. Pass it as the `token` of a Hocuspocus provider + whose `name` is the `documentName`, connecting to the `collaborationServer` link. +required: + - _type + - token + - documentName + - expiresAt + - expiresInSeconds + - _links +properties: + _type: + type: string + enum: + - CollaborationToken + readOnly: true + token: + type: string + description: The opaque, encrypted token to hand to the collaboration server. + readOnly: true + documentName: + type: string + format: uri + description: The name of the document on the collaboration server. It is the absolute API URL of the document. + readOnly: true + expiresAt: + type: string + format: date-time + description: The time the token expires at. + readOnly: true + expiresInSeconds: + type: integer + description: The number of seconds until the token expires. + readOnly: true + _links: + type: object + required: + - document + - createCollaborationToken + - collaborationServer + readOnly: true + properties: + document: + allOf: + - "$ref": "./link.yml" + - description: |- + The document the token grants access to + + **Resource**: Document + readOnly: true + createCollaborationToken: + allOf: + - "$ref": "./link.yml" + - description: |- + Creates a new token. Send this token as `token` to revoke it. + readOnly: true + collaborationServer: + allOf: + - "$ref": "./link.yml" + - description: |- + The WebSocket URL of the real-time collaboration server + readOnly: true +example: + _type: CollaborationToken + token: "Q2lwaGVydGV4dA==--SXZJdlZJdg==--QXV0aFRhZw==" + documentName: "https://openproject.example.com/api/v3/documents/1" + expiresAt: '2025-01-04T09:05:00Z' + expiresInSeconds: 300 + _links: + document: + href: "/api/v3/documents/1" + title: Some document + createCollaborationToken: + href: "/api/v3/documents/1/collaboration_token" + method: post + collaborationServer: + href: "wss://hocuspocus.example.com" diff --git a/docs/api/apiv3/components/schemas/document_model.yml b/docs/api/apiv3/components/schemas/document_model.yml index 96848ca34628..42acf192650e 100644 --- a/docs/api/apiv3/components/schemas/document_model.yml +++ b/docs/api/apiv3/components/schemas/document_model.yml @@ -51,6 +51,18 @@ properties: The attachments belonging to the document **Resource**: []Attachment + createCollaborationToken: + allOf: + - "$ref": "./link.yml" + - description: |- + Creates a token for the real-time collaboration server + + # Conditions + + **Permission**: view documents + + The document is collaborative and real-time collaboration is enabled. + readOnly: true example: _type: Document id: 1 @@ -75,6 +87,9 @@ example: addAttachment: href: "/api/v3/documents/1/attachments" method: post + createCollaborationToken: + href: "/api/v3/documents/1/collaboration_token" + method: post self: href: "/api/v3/documents/1" title: Some document diff --git a/docs/api/apiv3/openapi-spec.yml b/docs/api/apiv3/openapi-spec.yml index 5212453005fb..b81bf5634306 100644 --- a/docs/api/apiv3/openapi-spec.yml +++ b/docs/api/apiv3/openapi-spec.yml @@ -255,6 +255,8 @@ paths: "$ref": "./paths/documents.yml" "/api/v3/documents/{id}": "$ref": "./paths/document.yml" + "/api/v3/documents/{id}/collaboration_token": + "$ref": "./paths/document_collaboration_token.yml" "/api/v3/file_links/{id}": "$ref": "./paths/file_link.yml" "/api/v3/file_links/{id}/open": @@ -888,6 +890,8 @@ components: "$ref": "./components/schemas/categories_by_workspace_model.yml" CategoryModel: "$ref": "./components/schemas/category_model.yml" + CollaborationTokenModel: + "$ref": "./components/schemas/collaboration_token_model.yml" CollectionLinks: "$ref": "./components/schemas/collection_links.yml" CollectionModel: diff --git a/docs/api/apiv3/paths/document.yml b/docs/api/apiv3/paths/document.yml index 1075508084e1..dd76eda320d9 100644 --- a/docs/api/apiv3/paths/document.yml +++ b/docs/api/apiv3/paths/document.yml @@ -34,6 +34,9 @@ get: method: post attachments: href: "/api/v3/documents/1/attachments" + createCollaborationToken: + href: "/api/v3/documents/1/collaboration_token" + method: post project: href: "/api/v3/projects/19" title: Some project @@ -142,6 +145,9 @@ patch: method: post attachments: href: "/api/v3/documents/1/attachments" + createCollaborationToken: + href: "/api/v3/documents/1/collaboration_token" + method: post project: href: "/api/v3/projects/19" title: Some project diff --git a/docs/api/apiv3/paths/document_collaboration_token.yml b/docs/api/apiv3/paths/document_collaboration_token.yml new file mode 100644 index 000000000000..f54cdab7bc9c --- /dev/null +++ b/docs/api/apiv3/paths/document_collaboration_token.yml @@ -0,0 +1,146 @@ +# /api/v3/documents/{id}/collaboration_token +--- +post: + parameters: + - description: Document id + example: 1 + in: path + name: id + required: true + schema: + type: integer + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + token: + type: string + description: |- + A previously issued collaboration token to revoke 30 seconds after the new token is created. + The previous token may be expired, but must not be revoked (i.e. must not have been sent before). + Omit it to create a new token without revoking any. + examples: + create: + summary: Create a new token + value: {} + withPreviousToken: + summary: Create a new token and revoke the previous one + value: + token: "Q2lwaGVydGV4dA==--SXZJdlZJdg==--QXV0aFRhZw==" + responses: + '201': + content: + application/hal+json: + examples: + response: + value: + _type: CollaborationToken + token: "Q2lwaGVydGV4dA==--SXZJdlZJdg==--QXV0aFRhZw==" + documentName: "https://openproject.example.com/api/v3/documents/1" + expiresAt: '2025-01-04T09:05:00Z' + expiresInSeconds: 300 + _links: + document: + href: "/api/v3/documents/1" + title: Some document + createCollaborationToken: + href: "/api/v3/documents/1/collaboration_token" + method: post + collaborationServer: + href: "wss://hocuspocus.example.com" + schema: + "$ref": "../components/schemas/collaboration_token_model.yml" + description: "Created. The response must not be cached (`Cache-Control: no-store`)." + headers: {} + '403': + content: + application/hal+json: + schema: + $ref: "../components/schemas/error_response.yml" + examples: + response: + value: + _type: Error + errorIdentifier: urn:openproject-org:api:v3:errors:MissingPermission + message: Requests authenticated with a collaboration token cannot request collaboration tokens. + description: |- + Returned if the request is authenticated with a collaboration token, i.e. the OAuth access token + inside it. Such tokens can not request further tokens. + headers: {} + '404': + content: + application/hal+json: + schema: + $ref: "../components/schemas/error_response.yml" + examples: + response: + value: + _type: Error + errorIdentifier: urn:openproject-org:api:v3:errors:NotFound + message: The requested resource could not be found. + description: |- + Returned if the document does not exist or if the user does not have permission to view it. + + **Required permission** `view documents` in the project the document belongs to + headers: {} + '422': + content: + application/hal+json: + schema: + $ref: "../components/schemas/error_response.yml" + examples: + not_collaborative: + value: + _type: Error + errorIdentifier: urn:openproject-org:api:v3:errors:UnprocessableContent + message: The document does not support real-time collaboration. + collaboration_disabled: + value: + _type: Error + errorIdentifier: urn:openproject-org:api:v3:errors:UnprocessableContent + message: Real-time collaboration is not enabled. + invalid_previous_token: + value: + _type: Error + errorIdentifier: urn:openproject-org:api:v3:errors:UnprocessableContent + message: The previous collaboration token is invalid, already revoked, or does not belong to this + document and user. + description: |- + Returned if the document is not collaborative, if real-time collaboration is disabled, or if the + previous token is invalid, was issued for another document, belongs to another user or is already + revoked. Nothing is created or revoked in that case. + headers: {} + '500': + content: + application/hal+json: + schema: + $ref: "../components/schemas/error_response.yml" + examples: + response: + value: + _type: Error + errorIdentifier: urn:openproject-org:api:v3:errors:InternalServerError + message: An internal error has occurred. + description: Returned if the token could not be issued. Nothing is revoked in that case. + headers: {} + tags: + - Documents + description: |- + Creates a short-lived token for the real-time collaboration (Hocuspocus) server for the document. + + The token is issued for the requesting user. Users who may only view documents receive a read-only token. + Requests authenticated with a token issued to the collaboration server are rejected. + + To revoke the previous token while creating a new one, send it as `token` in the request body. The request + is authorized by the client's own credentials, exactly like creating a token without one. The previous token + only identifies which token to revoke, so it may be expired. This allows clients to recover after sleep or + offline periods. The previous token must not be revoked (i.e. must not have been sent before), otherwise + the request is rejected. + + The previous token is revoked 30 seconds after the new token is created. Clients should hand the new token + to the collaboration server right away, e.g. by calling `sendToken()` on the Hocuspocus provider. + operationId: Create_document_collaboration_token + summary: Create collaboration token diff --git a/docs/api/apiv3/tags/documents.yml b/docs/api/apiv3/tags/documents.yml index 971203d4ba2e..c71304e77f41 100644 --- a/docs/api/apiv3/tags/documents.yml +++ b/docs/api/apiv3/tags/documents.yml @@ -2,11 +2,34 @@ description: |- A document is a file containing a list of attachments. - *Please note, that the endpoint is only a stub for now.* - ## Actions - None yet + | Link | Description | Condition | + | :----------------------: | --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | + | update | Updates the document | **Permission**: manage documents | + | addAttachment | Adds an attachment to the document | **Permission**: manage documents | + | createCollaborationToken | Creates a token for the real-time collaboration server | **Permission**: view documents. The document is collaborative and real-time collaboration is enabled. | + + ## Real-time collaboration + + Collaborative documents are edited through the real-time collaboration server (Hocuspocus). To connect to it: + + 1. Create a collaboration token with `POST /api/v3/documents/{id}/collaboration_token` + (the `createCollaborationToken` link of the document). + 2. Connect a Hocuspocus provider to the `collaborationServer` link of the response, using `documentName` as + the provider's `name` and `token` as its `token`. + 3. Before `expiresInSeconds` runs out, create a new token, sending the current one as `token` so it is revoked + 30 seconds later (the `createCollaborationToken` link of the response). Doing so at about 80% of the + lifetime is recommended, like the OpenProject web frontend does. Hand the new token to the server right away + by calling `sendToken()` on the provider. A token that is already revoked (i.e. was sent before) is rejected. + 4. If the WebSocket connection is closed with code `4401`, create a new token and reconnect. + + Limitations: + + - Requests authenticated with a collaboration token cannot create further collaboration tokens. + Do that with your own credentials (session, API key or OAuth token). + - The collaboration server rejects browser connections whose `Origin` does not match the OpenProject + host. Server-side clients that send no `Origin` header are not affected. ## Linked Properties diff --git a/modules/documents/app/controllers/documents/refresh_tokens_controller.rb b/modules/documents/app/controllers/documents/refresh_tokens_controller.rb index f0ae273de24b..39833ef3a7db 100644 --- a/modules/documents/app/controllers/documents/refresh_tokens_controller.rb +++ b/modules/documents/app/controllers/documents/refresh_tokens_controller.rb @@ -36,8 +36,7 @@ class RefreshTokensController < ApplicationController def create token_result = Documents::OAuth::TokenWithMetadataService.new( user: current_user, - document: @document, - project: @project + document: @document ).call if token_result.success? diff --git a/modules/documents/app/controllers/documents_controller.rb b/modules/documents/app/controllers/documents_controller.rb index a80a1a406a90..89e6e01cfbbe 100644 --- a/modules/documents/app/controllers/documents_controller.rb +++ b/modules/documents/app/controllers/documents_controller.rb @@ -59,7 +59,7 @@ def search def show @attachments = @document.attachments.order(Arel.sql("created_at DESC")) - if @document.collaborative? && Setting.real_time_text_collaboration_enabled? + if @document.real_time_collaboration_available? setup_collaboration_context derive_show_edit_state_from_params end @@ -219,7 +219,7 @@ def setup_collaboration_context # rubocop:disable Metrics/AbcSize return unless current_user.allowed_in_project?(:view_documents, @project) token_result = Documents::OAuth::TokenWithMetadataService - .new(user: current_user, document: @document, project: @project) + .new(user: current_user, document: @document) .call if token_result.failure? diff --git a/modules/documents/app/models/document.rb b/modules/documents/app/models/document.rb index b2117ae4a547..ddfc2b8dd0c5 100644 --- a/modules/documents/app/models/document.rb +++ b/modules/documents/app/models/document.rb @@ -73,6 +73,10 @@ def visible?(user = User.current) !user.nil? && user.allowed_in_project?(:view_documents, project) end + def real_time_collaboration_available? + collaborative? && Setting.real_time_text_collaboration_enabled? + end + def set_default_type self.type ||= DocumentType.default if new_record? end diff --git a/modules/documents/app/services/documents/oauth/decrypt_token_service.rb b/modules/documents/app/services/documents/oauth/decrypt_token_service.rb new file mode 100644 index 000000000000..aec80cfeebb9 --- /dev/null +++ b/modules/documents/app/services/documents/oauth/decrypt_token_service.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Documents + module OAuth + ## + # Decrypts a collaboration token created by {EncryptTokenService}. + # + # Fails when the token was tampered with, was encrypted with another secret + # or is not a valid encrypted message at all. + class DecryptTokenService < BaseServices::BaseCallable + include TokenEncryptor + + def initialize(token:) + super() + + @token = token + end + + def perform + decrypted = message_encryptor.decrypt_and_verify(token.to_s) + + if decrypted.nil? + ServiceResult.failure(message: "Token could not be decrypted.") + else + ServiceResult.success(result: decrypted) + end + rescue StandardError => e + ServiceResult.failure(errors: e, message: "Token could not be decrypted.") + end + + private + + attr_reader :token + + def cipher_operation + "decrypt" + end + end + end +end diff --git a/modules/documents/app/services/documents/oauth/encrypt_token_service.rb b/modules/documents/app/services/documents/oauth/encrypt_token_service.rb index a5e8a49e7c8a..bc91b66ae94e 100644 --- a/modules/documents/app/services/documents/oauth/encrypt_token_service.rb +++ b/modules/documents/app/services/documents/oauth/encrypt_token_service.rb @@ -30,7 +30,7 @@ module Documents module OAuth class EncryptTokenService < BaseServices::BaseCallable - ALGORITHM = "aes-256-gcm" + include TokenEncryptor def initialize(token:) super() @@ -39,12 +39,7 @@ def initialize(token:) end def perform - encryptor = ActiveSupport::MessageEncryptor.new( - key, - cipher: ALGORITHM, - serializer: ActiveSupport::MessageEncryptor::NullSerializer - ) - encrypted = encryptor.encrypt_and_sign(token) + encrypted = message_encryptor.encrypt_and_sign(token) ServiceResult.success(result: encrypted) rescue StandardError => e @@ -55,13 +50,8 @@ def perform attr_reader :token - def key - @key ||= begin - secret = Setting.collaborative_editing_hocuspocus_secret - raise "Collaborative editing secret is not set. Cannot encrypt token." if secret.blank? - - Digest::SHA256.digest(secret) - end + def cipher_operation + "encrypt" end end end diff --git a/modules/documents/app/services/documents/oauth/ensure_application_service.rb b/modules/documents/app/services/documents/oauth/ensure_application_service.rb index 18b47ee5d263..1626c5583011 100644 --- a/modules/documents/app/services/documents/oauth/ensure_application_service.rb +++ b/modules/documents/app/services/documents/oauth/ensure_application_service.rb @@ -39,6 +39,13 @@ class EnsureApplicationService < BaseServices::BaseCallable APPLICATION_NAME = "Documents OAuth Application" APPLICATION_UID = "documents_yjs_provider" + ## + # Whether the given Doorkeeper access token was issued to the Documents OAuth application, + # i.e. it is a token handed to the collaboration (Hocuspocus) server. + def self.collaboration_token?(access_token) + access_token&.application&.uid == APPLICATION_UID + end + def perform application = find_or_create_application diff --git a/modules/documents/app/services/documents/oauth/token_encryptor.rb b/modules/documents/app/services/documents/oauth/token_encryptor.rb new file mode 100644 index 000000000000..5e3db5e60165 --- /dev/null +++ b/modules/documents/app/services/documents/oauth/token_encryptor.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Documents + module OAuth + ## + # Shared cipher configuration for the collaboration token sent to the Hocuspocus server. + # Including classes define `cipher_operation` ("encrypt" or "decrypt") for error messages. + # + # The Hocuspocus server decrypts the token with the same algorithm and the SHA256 digest + # of the shared secret, so both directions must stay in sync. + module TokenEncryptor + ALGORITHM = "aes-256-gcm" + + private + + def message_encryptor + ActiveSupport::MessageEncryptor.new( + key, + cipher: ALGORITHM, + serializer: ActiveSupport::MessageEncryptor::NullSerializer + ) + end + + def key + @key ||= begin + secret = Setting.collaborative_editing_hocuspocus_secret + raise "Collaborative editing secret is not set. Cannot #{cipher_operation} token." if secret.blank? + + Digest::SHA256.digest(secret) + end + end + end + end +end diff --git a/modules/documents/app/services/documents/oauth/token_with_metadata_service.rb b/modules/documents/app/services/documents/oauth/token_with_metadata_service.rb index b43c8eca8d62..b308ede7d252 100644 --- a/modules/documents/app/services/documents/oauth/token_with_metadata_service.rb +++ b/modules/documents/app/services/documents/oauth/token_with_metadata_service.rb @@ -29,25 +29,69 @@ module Documents module OAuth + ## + # Creates a collaboration token for the document. + # + # When a `previous_token` is given, it is revoked 30 seconds after the new token is created, + # so the collaboration server can keep using it until the client has handed the new token over. + # The previous token may be expired, but must not be revoked (i.e. must not have been sent before). class TokenWithMetadataService < BaseServices::BaseCallable include API::V3::Utilities::PathHelper - attr_reader :user, :document, :project + PREVIOUS_TOKEN_REVOCATION_DELAY = 30.seconds - def initialize(user:, document:, project:) + attr_reader :user, :document, :previous_token, :requesting_access_token + + def initialize(user:, document:, previous_token: nil, requesting_access_token: nil) super() @user = user @document = document - @project = project + @previous_token = previous_token + @requesting_access_token = requesting_access_token + end + + def perform + precondition_failure = check_preconditions + return precondition_failure if precondition_failure + + token_result = nil + + ::Doorkeeper::AccessToken.transaction do + token_result = previous_token ? revoke_previous_and_create_token : create_token_with_metadata + raise ActiveRecord::Rollback if token_result.failure? + end + + token_result + end + + def resource_url + @resource_url ||= URI.join( + OpenProject::StaticRouting::StaticUrlHelpers.new.root_url, + api_v3_paths.document(document.id) + ).to_s + end + + private + + def check_preconditions + if EnsureApplicationService.collaboration_token?(requesting_access_token) + collaboration_token_not_allowed_result + elsif !document.collaborative? + not_collaborative_result + elsif !document.real_time_collaboration_available? + collaboration_disabled_result + elsif previous_token && previous_access_token.nil? + invalid_previous_token_result + end end - def perform # rubocop:disable Metrics/AbcSize + def create_token_with_metadata # rubocop:disable Metrics/AbcSize token_result = GenerateTokenService.new(user:).call if token_result.failure? Rails.logger.error("Failed to generate OAuth token for document #{document.id}: #{token_result.errors}") - return token_result + return token_creation_failed_result end access_token = token_result.result @@ -64,7 +108,7 @@ def perform # rubocop:disable Metrics/AbcSize if encrypted_result.failure? Rails.logger.error("Failed to encrypt OAuth token payload for document #{document.id}: #{encrypted_result.errors}") - return encrypted_result + return token_creation_failed_result end ServiceResult.success( @@ -78,18 +122,78 @@ def perform # rubocop:disable Metrics/AbcSize ) end - private + def revoke_previous_and_create_token + return invalid_previous_token_result if revoke_previous_token.zero? - def resource_url - @resource_url ||= URI.join( - OpenProject::StaticRouting::StaticUrlHelpers.new.root_url, - api_v3_paths.document(document.id) - ).to_s + create_token_with_metadata end def readonly - @readonly ||= user.allowed_in_project?(:view_documents, project) && - !user.allowed_in_project?(:manage_documents, project) + @readonly ||= user.allowed_in_project?(:view_documents, document.project) && + !user.allowed_in_project?(:manage_documents, document.project) + end + + def previous_access_token + @previous_access_token ||= find_previous_access_token + end + + def find_previous_access_token + payload = decrypted_payload + return if payload.nil? + return if payload["resource_url"] != resource_url + + access_token = ::Doorkeeper::AccessToken.by_token(payload["oauth_token"]) + return unless EnsureApplicationService.collaboration_token?(access_token) + return if access_token.resource_owner_id != user.id + return if access_token.revoked_at.present? + + access_token + end + + def decrypted_payload + decrypt_result = DecryptTokenService.new(token: previous_token).call + return unless decrypt_result.success? + + payload = JSON.parse(decrypt_result.result) + payload if payload.is_a?(Hash) + rescue JSON::ParserError + nil + end + + # Doorkeeper considers a token revoked once `revoked_at <= now`, so a value in the + # future keeps it valid until then. + # Only revokes if not revoked yet, so that concurrent requests cannot both revoke the same token. + def revoke_previous_token + ::Doorkeeper::AccessToken + .where(id: previous_access_token.id, revoked_at: nil) + .update_all(revoked_at: PREVIOUS_TOKEN_REVOCATION_DELAY.from_now) + end + + def collaboration_token_not_allowed_result + typed_failure(:base, :collaboration_token_not_allowed, + I18n.t("documents.collaboration_token.errors.collaboration_token_not_allowed")) + end + + def not_collaborative_result + typed_failure(:document, :not_collaborative, I18n.t("documents.collaboration_token.errors.not_collaborative")) + end + + def collaboration_disabled_result + typed_failure(:document, :collaboration_disabled, I18n.t("documents.collaboration_token.errors.collaboration_disabled")) + end + + def invalid_previous_token_result + typed_failure(:token, :invalid, I18n.t("documents.collaboration_token.errors.invalid_previous_token")) + end + + def token_creation_failed_result + ServiceResult.failure(message: I18n.t("api_v3.errors.code_500")) + end + + def typed_failure(attribute, type, message) + ServiceResult + .failure(message:) + .tap { |result| result.errors.add(attribute, type) } end end end diff --git a/modules/documents/config/locales/en.yml b/modules/documents/config/locales/en.yml index 1ffd055ed18b..0b25b4fd556d 100644 --- a/modules/documents/config/locales/en.yml +++ b/modules/documents/config/locales/en.yml @@ -96,6 +96,12 @@ en: success: Real-time collaboration has been enabled. button_add_type: "Type" + collaboration_token: + errors: + collaboration_disabled: "Real-time collaboration is not enabled." + collaboration_token_not_allowed: "Requests authenticated with a collaboration token cannot request collaboration tokens." + invalid_previous_token: "The previous collaboration token is invalid, already revoked, or does not belong to this document and user." + not_collaborative: "The document does not support real-time collaboration." delete_dialog: confirmation_message_html: "This will permanently delete this document and all file attachments. Are you sure you want to do this?" heading: "Delete this document?" diff --git a/modules/documents/lib/api/v3/documents/collaboration_token_api.rb b/modules/documents/lib/api/v3/documents/collaboration_token_api.rb new file mode 100644 index 000000000000..bffa1cc0a391 --- /dev/null +++ b/modules/documents/lib/api/v3/documents/collaboration_token_api.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module API + module V3 + module Documents + class CollaborationTokenAPI < ::API::OpenProjectAPI + resources :collaboration_token do + helpers do + def request_access_token + ::Doorkeeper::OAuth::Token.authenticate( + ::Doorkeeper::Grape::AuthorizationDecorator.new(request), + *Doorkeeper.configuration.access_token_methods + ) + end + end + + after_validation do + authorize_in_project(:view_documents, project: document.project) + end + + params do + optional :token, type: String, desc: "A previously issued collaboration token to revoke" + end + + post do + call = ::Documents::OAuth::TokenWithMetadataService + .new(user: current_user, + document:, + previous_token: declared_params[:token].presence, + requesting_access_token: request_access_token) + .call + + if call.failure? + if call.includes_error?(:base, :collaboration_token_not_allowed) + raise ::API::Errors::Unauthorized.new(message: call.message) + elsif call.includes_error?(:document, :not_collaborative) || + call.includes_error?(:document, :collaboration_disabled) || + call.includes_error?(:token, :invalid) + raise ::API::Errors::UnprocessableContent.new(call.message) + end + + raise ::API::Errors::SafeInternalError.new(I18n.t("api_v3.errors.code_500")) + end + + header "Cache-Control", "no-store" + status 201 + + CollaborationTokenRepresenter.new( + CollaborationTokenRepresenter::CollaborationToken.from_token_result(document, call.result), + current_user: + ) + end + end + end + end + end +end diff --git a/modules/documents/lib/api/v3/documents/collaboration_token_representer.rb b/modules/documents/lib/api/v3/documents/collaboration_token_representer.rb new file mode 100644 index 000000000000..c925b6cd6c82 --- /dev/null +++ b/modules/documents/lib/api/v3/documents/collaboration_token_representer.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module API + module V3 + module Documents + ## + # Renders a freshly issued collaboration token for a document. + # + # Deliberately not a cached representer: the token is specific to the requesting user + # and must never be served to anybody else. + class CollaborationTokenRepresenter < ::API::Decorators::Single + CollaborationToken = Data.define(:document, :token, :document_name, :expires_at, :expires_in_seconds) do + def self.from_token_result(document, token_result) + new(document:, + token: token_result[:encrypted_token], + document_name: token_result[:resource_url], + expires_at: token_result[:expires_at], + expires_in_seconds: token_result[:expires_in_seconds]) + end + end + + link :document do + { + href: api_v3_paths.document(represented.document.id), + title: represented.document.title + } + end + + link :createCollaborationToken do + { + href: api_v3_paths.document_collaboration_token(represented.document.id), + method: :post + } + end + + link :collaborationServer do + { + href: Setting.collaborative_editing_hocuspocus_url + } + end + + property :token + + property :document_name + + property :expires_at + + property :expires_in_seconds + + def _type + "CollaborationToken" + end + end + end + end +end diff --git a/modules/documents/lib/api/v3/documents/document_representer.rb b/modules/documents/lib/api/v3/documents/document_representer.rb index 5135681b239d..d8e5549a2438 100644 --- a/modules/documents/lib/api/v3/documents/document_representer.rb +++ b/modules/documents/lib/api/v3/documents/document_representer.rb @@ -39,6 +39,7 @@ class DocumentRepresenter < ::API::Decorators::Single include ::API::V3::Attachments::AttachableRepresenterMixin cached_representer key_parts: %i(project), + dependencies: -> { Setting.real_time_text_collaboration_enabled? }, disabled: false self_link title_getter: ->(*) { represented.title } @@ -51,6 +52,16 @@ class DocumentRepresenter < ::API::Decorators::Single } end + link :createCollaborationToken, + cache_if: -> { current_user.allowed_in_project?(:view_documents, represented.project) } do + next unless represented.real_time_collaboration_available? + + { + href: api_v3_paths.document_collaboration_token(represented.id), + method: :post + } + end + property :id property :title diff --git a/modules/documents/lib/api/v3/documents/documents_api.rb b/modules/documents/lib/api/v3/documents/documents_api.rb index 519bd805c0ed..b6a206359687 100644 --- a/modules/documents/lib/api/v3/documents/documents_api.rb +++ b/modules/documents/lib/api/v3/documents/documents_api.rb @@ -56,7 +56,7 @@ class DocumentsAPI < ::API::OpenProjectAPI route_param :id, type: Integer, desc: "Document ID" do helpers do def document - Document.visible.find(params[:id]) + @document ||= Document.visible.find(params[:id]) end end @@ -83,6 +83,7 @@ def document end end + mount ::API::V3::Documents::CollaborationTokenAPI mount ::API::V3::Attachments::AttachmentsByDocumentAPI end end diff --git a/modules/documents/lib/open_project/documents/engine.rb b/modules/documents/lib/open_project/documents/engine.rb index fe807e947ffe..5b4c6aca7f48 100644 --- a/modules/documents/lib/open_project/documents/engine.rb +++ b/modules/documents/lib/open_project/documents/engine.rb @@ -111,6 +111,10 @@ class Engine < ::Rails::Engine add_api_resource_paths :document, only: %i[index show] + add_api_path :document_collaboration_token do |id| + "#{document(id)}/collaboration_token" + end + add_api_path :attachments_by_document do |id| "#{document(id)}/attachments" end diff --git a/modules/documents/spec/api/v3/documents/document_representer_rendering_spec.rb b/modules/documents/spec/api/v3/documents/document_representer_rendering_spec.rb index a8df0f605a8d..aaf88b3534b9 100644 --- a/modules/documents/spec/api/v3/documents/document_representer_rendering_spec.rb +++ b/modules/documents/spec/api/v3/documents/document_representer_rendering_spec.rb @@ -84,6 +84,91 @@ let(:link) { :update } end end + + describe "createCollaborationToken", with_settings: { real_time_text_collaboration_enabled: true } do + let(:link) { :createCollaborationToken } + let(:document_permissions) { %i(view_documents) } + + before do + mock_permissions_for(current_user) do |mock| + mock.allow_in_project(*document_permissions, project: workspace) if document_permissions.any? + end + end + + context "when the user may view documents" do + it_behaves_like "has an untitled link" do + let(:href) { api_v3_paths.document_collaboration_token document.id } + end + + it "indicates the POST method" do + expect(subject).to be_json_eql("post".to_json).at_path("_links/createCollaborationToken/method") + end + end + + context "when the user may manage documents" do + let(:document_permissions) { %i(view_documents manage_documents) } + + it_behaves_like "has an untitled link" do + let(:href) { api_v3_paths.document_collaboration_token document.id } + end + end + + context "when the user may not view documents" do + let(:document_permissions) { [] } + + it_behaves_like "has no link" + end + + context "when the document is not collaborative" do + let(:document) do + build_stubbed(:document, kind: "classic") do |document| + allow(document) + .to receive(:project) + .and_return(workspace) + end + end + + it_behaves_like "has no link" + end + + context "when real-time collaboration is disabled", + with_settings: { real_time_text_collaboration_enabled: false } do + it_behaves_like "has no link" + end + + context "when real-time collaboration is disabled after the representer has been cached" do + it "no longer renders the link" do + expect(described_class.create(document, current_user:, embed_links:).to_json) + .to have_json_path("_links/createCollaborationToken") + + allow(Setting).to receive(:real_time_text_collaboration_enabled?).and_return(false) + + expect(described_class.create(document, current_user:, embed_links:).to_json) + .not_to have_json_path("_links/createCollaborationToken") + end + end + + context "when rendered for a permitted user first and a user without permission afterwards" do + let(:other_user) { build_stubbed(:user) } + let(:permitted_representer) { described_class.create(document, current_user:, embed_links:) } + let(:unpermitted_representer) { described_class.create(document, current_user: other_user, embed_links:) } + + before do + mock_permissions_for(other_user, &:forbid_everything) + end + + it "does not leak the link through the representer cache" do + expect(permitted_representer.to_json).to have_json_path("_links/createCollaborationToken") + + # Make sure the second rendering is actually served from the cache filled by the first one. + expect(unpermitted_representer.json_cache_key).to eq(permitted_representer.json_cache_key) + expect(Rails.cache.exist?(OpenProject::Cache::CacheKey.key(permitted_representer.json_cache_key))) + .to be(true) + + expect(unpermitted_representer.to_json).not_to have_json_path("_links/createCollaborationToken") + end + end + end end describe "properties" do diff --git a/modules/documents/spec/api/v3/utilities/path_helper_spec.rb b/modules/documents/spec/api/v3/utilities/path_helper_spec.rb index f57fd2c9891d..fb2515b7dda2 100644 --- a/modules/documents/spec/api/v3/utilities/path_helper_spec.rb +++ b/modules/documents/spec/api/v3/utilities/path_helper_spec.rb @@ -43,4 +43,10 @@ it { is_expected.to eql("/api/v3/documents/42/attachments/prepare") } end + + describe "#document_collaboration_token" do + subject { helper.document_collaboration_token 42 } + + it { is_expected.to eql("/api/v3/documents/42/collaboration_token") } + end end diff --git a/modules/documents/spec/controllers/documents/refresh_tokens_controller_spec.rb b/modules/documents/spec/controllers/documents/refresh_tokens_controller_spec.rb index bd76a09eea7a..9799c286bd0b 100644 --- a/modules/documents/spec/controllers/documents/refresh_tokens_controller_spec.rb +++ b/modules/documents/spec/controllers/documents/refresh_tokens_controller_spec.rb @@ -29,7 +29,7 @@ require "spec_helper" -RSpec.describe Documents::RefreshTokensController do +RSpec.describe Documents::RefreshTokensController, with_settings: { real_time_text_collaboration_enabled: true } do let(:project) { create(:project) } let(:document) { create(:document, project:) } let(:user) { create(:user) } @@ -101,6 +101,24 @@ end end + context "when the document is not collaborative" do + let(:document) { create(:document, project:, kind: :classic) } + + before do + login_as(user) + create(:member, project:, user:, roles: [role]) + end + + it "returns unprocessable entity without creating a token" do + expect do + post :create, params: { project_id: project.id, document_id: document.id } + end.not_to change(Doorkeeper::AccessToken, :count) + + expect(response).to have_http_status(:unprocessable_entity) + expect(response.parsed_body["error"]).to eq(I18n.t("documents.collaboration_token.errors.not_collaborative")) + end + end + context "when token generation fails" do before do login_as(user) diff --git a/modules/documents/spec/models/document_spec.rb b/modules/documents/spec/models/document_spec.rb index 348c70a03eae..a39246f14d17 100644 --- a/modules/documents/spec/models/document_spec.rb +++ b/modules/documents/spec/models/document_spec.rb @@ -109,6 +109,30 @@ end end + describe "#real_time_collaboration_available?" do + let(:document) { build(:document, kind:) } + let(:kind) { "collaborative" } + let(:collaboration_enabled) { true } + + before do + allow(Setting).to receive(:real_time_text_collaboration_enabled?).and_return(collaboration_enabled) + end + + it { expect(document).to be_real_time_collaboration_available } + + context "when real-time collaboration is disabled" do + let(:collaboration_enabled) { false } + + it { expect(document).not_to be_real_time_collaboration_available } + end + + context "when the document is classic" do + let(:kind) { "classic" } + + it { expect(document).not_to be_real_time_collaboration_available } + end + end + describe "acts as event" do let(:now) { Time.zone.now } let(:document) do diff --git a/modules/documents/spec/requests/api/v3/documents/collaboration_token_resource_spec.rb b/modules/documents/spec/requests/api/v3/documents/collaboration_token_resource_spec.rb new file mode 100644 index 000000000000..402fd7f069a5 --- /dev/null +++ b/modules/documents/spec/requests/api/v3/documents/collaboration_token_resource_spec.rb @@ -0,0 +1,316 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" +require "rack/test" +require_relative "../../../../support/shared/collaboration_token_endpoint" + +RSpec.describe "API v3 document collaboration token resource" do + include Rack::Test::Methods + include API::V3::Utilities::PathHelper + + include_context "with a collaboration token endpoint setup" + + let(:path) { api_v3_paths.document_collaboration_token(document.id) } + let(:request_body) { nil } + + describe "POST /api/v3/documents/:id/collaboration_token" do + it_behaves_like "a guarded collaboration token endpoint" + + context "when logged in with the view_documents permission", + freeze_time: DateTime.parse("2025-01-04T09:00:00Z") do + let(:response_body) { JSON.parse(last_response.body) } + let(:expected_document_name) { "http://#{Setting.host_name}#{api_v3_paths.document(document.id)}" } + + before do + login_as(current_user) + + post_collaboration_token_request + end + + it "responds with 201 and a collaboration token" do + expect(last_response).to have_http_status(:created) + + expect(response_body).to include( + "_type" => "CollaborationToken", + "documentName" => expected_document_name, + "expiresAt" => "2025-01-04T09:05:00Z", + "expiresInSeconds" => 300 + ) + end + + it "returns an encrypted token for the document and the user" do + payload = decrypt_collaboration_token(response_body["token"]) + + expect(payload["resource_url"]).to eq(expected_document_name) + expect(payload["expires_at"]).to eq("2025-01-04T09:05:00Z") + + access_token = Doorkeeper::AccessToken.by_token(payload["oauth_token"]) + expect(access_token.resource_owner_id).to eq(current_user.id) + expect(access_token.expires_in).to eq(5.minutes.to_i) + expect(access_token.application.uid).to eq(Documents::OAuth::EnsureApplicationService::APPLICATION_UID) + end + + it "does not expose the plain OAuth token" do + payload = decrypt_collaboration_token(response_body["token"]) + + expect(last_response.body).not_to include(payload["oauth_token"]) + end + + it "links the document, the createCollaborationToken action and the collaboration server" do + expect(last_response.body) + .to be_json_eql(api_v3_paths.document(document.id).to_json) + .at_path("_links/document/href") + expect(last_response.body) + .to be_json_eql(api_v3_paths.document_collaboration_token(document.id).to_json) + .at_path("_links/createCollaborationToken/href") + expect(last_response.body) + .to be_json_eql("post".to_json) + .at_path("_links/createCollaborationToken/method") + expect(last_response.body) + .to be_json_eql(hocuspocus_url.to_json) + .at_path("_links/collaborationServer/href") + expect(last_response.body).not_to have_json_path("_links/self") + end + end + + context "when creating the token fails" do + before do + login_as(current_user) + + allow(Documents::OAuth::TokenWithMetadataService) + .to receive(:new) + .and_return(instance_double(Documents::OAuth::TokenWithMetadataService, + call: ServiceResult.failure(message: "Something went wrong"))) + + post_collaboration_token_request + end + + it "responds with 500" do + expect(last_response).to have_http_status(:internal_server_error) + expect(last_response.body).not_to include("Something went wrong") + end + end + + shared_examples "creates a new token without revoking any" do + before do + login_as(current_user) + end + + it "responds with 201" do + expect { post_collaboration_token_request }.to change(Doorkeeper::AccessToken, :count).by(1) + + expect(last_response).to have_http_status(:created) + expect(Doorkeeper::AccessToken.where.not(revoked_at: nil)).to be_empty + end + end + + context "with an empty request body" do + let(:request_body) { {} } + + it_behaves_like "creates a new token without revoking any" + end + + context "with a blank token" do + let(:request_body) { { token: "" } } + + it_behaves_like "creates a new token without revoking any" + end + + context "with a previous token" do + let(:previous_token) { mint_previous_token(user: current_user, document:) } + let(:request_body) { { token: previous_token } } + let(:previous_access_token) do + Doorkeeper::AccessToken.by_token(decrypt_collaboration_token(previous_token)["oauth_token"]) + end + + def mint_previous_token(user:, document:) + payload = { + resource_url: Documents::OAuth::TokenWithMetadataService.new(user:, document:).resource_url, + oauth_token: collaboration_access_token_for(user).plaintext_token, + expires_at: 5.minutes.from_now.iso8601, + readonly: true + } + + Documents::OAuth::EncryptTokenService.new(token: payload.to_json).call.result + end + + it_behaves_like "a guarded collaboration token endpoint" + + context "when logged in" do + let(:response_body) { JSON.parse(last_response.body) } + + before do + login_as(current_user) + end + + context "with a valid previous token", freeze_time: DateTime.parse("2025-01-04T09:00:00Z") do + let(:previous_oauth_token) { decrypt_collaboration_token(previous_token)["oauth_token"] } + + before do + previous_token + + post_collaboration_token_request + end + + it "responds with 201 and a new collaboration token" do + expect(last_response).to have_http_status(:created) + expect(last_response.headers["Cache-Control"]).to eq("no-store") + + expect(response_body).to include("_type" => "CollaborationToken", + "expiresAt" => "2025-01-04T09:05:00Z", + "expiresInSeconds" => 300) + expect(response_body["token"]).not_to eq(previous_token) + + payload = decrypt_collaboration_token(response_body["token"]) + expect(payload["oauth_token"]).not_to eq(previous_oauth_token) + expect(payload["resource_url"]).to eq(response_body["documentName"]) + end + + it "revokes the previous OAuth token 30 seconds later" do + expect(previous_access_token.revoked_at) + .to eq(Time.current + Documents::OAuth::TokenWithMetadataService::PREVIOUS_TOKEN_REVOCATION_DELAY) + end + + it "keeps the previous OAuth token usable until it is revoked" do + header "Authorization", "Bearer #{previous_oauth_token}" + + get api_v3_paths.document(document.id) + expect(last_response).to have_http_status(:ok) + + travel(Documents::OAuth::TokenWithMetadataService::PREVIOUS_TOKEN_REVOCATION_DELAY + 1.second) + + get api_v3_paths.document(document.id) + expect(last_response).to have_http_status(:unauthorized) + end + end + + context "with an expired previous token" do + before do + previous_token + travel 10.minutes + + post_collaboration_token_request + end + + it "responds with 201" do + expect(last_response).to have_http_status(:created) + expect(previous_access_token.revoked_at).to be_present + end + end + + shared_examples "rejects the already revoked previous token" do + before do + previous_access_token.update_column(:revoked_at, revoked_at) + end + + it "responds with 422 and neither creates a token nor changes the revocation" do + expect { post_collaboration_token_request }.not_to change(Doorkeeper::AccessToken, :count) + + expect(last_response).to have_http_status(422) + expect(last_response.body) + .to be_json_eql(I18n.t("documents.collaboration_token.errors.invalid_previous_token").to_json) + .at_path("message") + expect(previous_access_token.reload.revoked_at).to eq(revoked_at) + end + end + + context "with an already revoked previous token" do + let(:revoked_at) { 1.minute.ago.change(usec: 0) } + + it_behaves_like "rejects the already revoked previous token" + end + + context "with an already revoked previous token that is still within the revocation delay" do + let(:revoked_at) { 10.seconds.from_now.change(usec: 0) } + + it_behaves_like "rejects the already revoked previous token" + end + + shared_examples "rejects the previous token" do + it "responds with 422 and neither revokes nor creates a token" do + previous_token + + expect { post_collaboration_token_request }.not_to change(Doorkeeper::AccessToken, :count) + + expect(last_response).to have_http_status(422) + expect(last_response.body) + .to be_json_eql("urn:openproject-org:api:v3:errors:UnprocessableContent".to_json) + .at_path("errorIdentifier") + expect(last_response.body) + .to be_json_eql(I18n.t("documents.collaboration_token.errors.invalid_previous_token").to_json) + .at_path("message") + expect(Doorkeeper::AccessToken.where.not(revoked_at: nil)).to be_empty + end + end + + context "with a garbage token" do + let(:previous_token) { "garbage" } + + it_behaves_like "rejects the previous token" + end + + context "with a tampered token" do + let(:previous_token) { mint_previous_token(user: current_user, document:).reverse } + + it_behaves_like "rejects the previous token" + end + + context "with a token for another document" do + let(:previous_token) { mint_previous_token(user: current_user, document: create(:document, project:)) } + + it_behaves_like "rejects the previous token" + end + + context "with a token belonging to another user" do + let(:other_user) { create(:user, member_with_roles: { project => role }) } + let(:previous_token) { mint_previous_token(user: other_user, document:) } + + it_behaves_like "rejects the previous token" + end + + context "with a token whose OAuth token was not issued to the Documents OAuth application" do + let(:previous_token) do + payload = { + resource_url: "http://#{Setting.host_name}#{api_v3_paths.document(document.id)}", + oauth_token: create(:oauth_access_token, resource_owner: current_user).plaintext_token, + expires_at: 5.minutes.from_now.iso8601, + readonly: true + } + + Documents::OAuth::EncryptTokenService.new(token: payload.to_json).call.result + end + + it_behaves_like "rejects the previous token" + end + end + end + end +end diff --git a/modules/documents/spec/services/documents/oauth/decrypt_token_service_spec.rb b/modules/documents/spec/services/documents/oauth/decrypt_token_service_spec.rb new file mode 100644 index 000000000000..5763c8a71368 --- /dev/null +++ b/modules/documents/spec/services/documents/oauth/decrypt_token_service_spec.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe Documents::OAuth::DecryptTokenService, + with_settings: { collaborative_editing_hocuspocus_secret: "test_secret_for_encryption" } do + subject(:service_call) { described_class.new(token:).call } + + let(:plaintext) { { resource_url: "http://example.com/api/v3/documents/1", oauth_token: "abc" }.to_json } + let(:encrypted) { Documents::OAuth::EncryptTokenService.new(token: plaintext).call.result } + + describe "#call" do + context "with a token encrypted by the EncryptTokenService" do + let(:token) { encrypted } + + it "returns the decrypted payload" do + expect(service_call).to be_success + expect(service_call.result).to eq(plaintext) + end + end + + context "with a tampered token" do + let(:token) do + data, iv, auth_tag = encrypted.split("--") + tampered = Base64.strict_encode64(Base64.strict_decode64(data).reverse) + [tampered, iv, auth_tag].join("--") + end + + it "fails" do + expect(service_call).to be_failure + end + end + + context "with garbage" do + let(:token) { "not-an-encrypted-token" } + + it "fails" do + expect(service_call).to be_failure + end + end + + context "with a nil token" do + let(:token) { nil } + + it "fails" do + expect(service_call).to be_failure + end + end + + context "with a token encrypted with another secret" do + let(:token) { encrypted } + + before do + token + allow(Setting).to receive(:collaborative_editing_hocuspocus_secret).and_return("another_secret") + end + + it "fails" do + expect(service_call).to be_failure + end + end + + context "when the secret is not set" do + let(:token) { encrypted } + + before do + token + allow(Setting).to receive(:collaborative_editing_hocuspocus_secret).and_return(nil) + end + + it "fails" do + expect(service_call).to be_failure + end + end + end +end diff --git a/modules/documents/spec/services/documents/oauth/ensure_application_service_spec.rb b/modules/documents/spec/services/documents/oauth/ensure_application_service_spec.rb index 3634256fd2c2..2068245f0ee5 100644 --- a/modules/documents/spec/services/documents/oauth/ensure_application_service_spec.rb +++ b/modules/documents/spec/services/documents/oauth/ensure_application_service_spec.rb @@ -67,4 +67,28 @@ end end end + + describe ".collaboration_token?" do + subject { described_class.collaboration_token?(access_token) } + + context "when the token was issued to the Documents OAuth application" do + let(:access_token) do + create(:oauth_access_token, application: create(:oauth_application, uid: described_class::APPLICATION_UID)) + end + + it { is_expected.to be(true) } + end + + context "when the token was issued to another OAuth application" do + let(:access_token) { create(:oauth_access_token) } + + it { is_expected.to be(false) } + end + + context "when there is no token" do + let(:access_token) { nil } + + it { is_expected.to be(false) } + end + end end diff --git a/modules/documents/spec/services/documents/oauth/token_with_metadata_service_spec.rb b/modules/documents/spec/services/documents/oauth/token_with_metadata_service_spec.rb index 939d46099e86..7867ecf9def4 100644 --- a/modules/documents/spec/services/documents/oauth/token_with_metadata_service_spec.rb +++ b/modules/documents/spec/services/documents/oauth/token_with_metadata_service_spec.rb @@ -30,8 +30,11 @@ require "spec_helper" RSpec.describe Documents::OAuth::TokenWithMetadataService, - with_settings: { collaborative_editing_hocuspocus_secret: "test_secret_for_encryption" } do - subject(:service_call) { described_class.new(user:, document:, project:).call } + with_settings: { collaborative_editing_hocuspocus_secret: "test_secret_for_encryption", + real_time_text_collaboration_enabled: true } do + subject(:service_call) { described_class.new(user:, document:, requesting_access_token:).call } + + let(:requesting_access_token) { nil } let(:project) { create(:project) } let(:document) { create(:document, project:) } @@ -93,6 +96,14 @@ def decrypt_token(encrypted_token) expect { service_call }.to change(Doorkeeper::AccessToken, :count).by(1) end + it "revokes no access token" do + create(:oauth_access_token, resource_owner: user) + + service_call + + expect(Doorkeeper::AccessToken.where.not(revoked_at: nil)).to be_empty + end + context "when user only has view_documents permission (readonly)" do let(:user) { create(:user, member_with_roles: { project => view_only_role }) } @@ -139,4 +150,276 @@ def decrypt_token(encrypted_token) "Collaborative editing secret is not set. Cannot encrypt token.") end end + + shared_examples "fails without creating a token" do |attribute, error, message_key| + it "fails with a #{error} error" do + expect(service_call).to be_failure + expect(service_call.includes_error?(attribute, error)).to be(true) + expect(service_call.message).to eq(I18n.t(message_key)) + end + + it "creates no access token" do + expect { service_call }.not_to change(Doorkeeper::AccessToken, :count) + end + end + + context "when the document is not collaborative" do + let(:document) { create(:document, project:, kind: :classic) } + + it_behaves_like "fails without creating a token", + :document, :not_collaborative, "documents.collaboration_token.errors.not_collaborative" + end + + context "when real time collaboration is disabled", with_settings: { real_time_text_collaboration_enabled: false } do + it_behaves_like "fails without creating a token", + :document, :collaboration_disabled, "documents.collaboration_token.errors.collaboration_disabled" + end + + context "when the requesting access token is a collaboration token" do + let!(:requesting_access_token) { Documents::OAuth::GenerateTokenService.new(user:).call.result } + + it_behaves_like "fails without creating a token", + :base, :collaboration_token_not_allowed, + "documents.collaboration_token.errors.collaboration_token_not_allowed" + end + + context "when the requesting access token is a regular access token" do + let(:requesting_access_token) { create(:oauth_access_token, resource_owner: user) } + + it "returns a new collaboration token" do + expect(service_call).to be_success + expect(service_call.result[:encrypted_token]).to be_present + end + end + + context "with a previous token" do + subject(:service_call) { described_class.new(user:, document:, previous_token:).call } + + let(:previous_token_result) { described_class.new(user:, document:).call.result } + let(:previous_token) { previous_token_result[:encrypted_token] } + let(:previous_access_token) { access_token_for(previous_token) } + + def decrypt_payload(encrypted_token) + JSON.parse(Documents::OAuth::DecryptTokenService.new(token: encrypted_token).call.result) + end + + def access_token_for(encrypted_token) + Doorkeeper::AccessToken.by_token(decrypt_payload(encrypted_token)["oauth_token"]) + end + + def encrypt(payload) + Documents::OAuth::EncryptTokenService.new(token: payload.to_json).call.result + end + + shared_examples "rejects the previous token" do + it "fails with an invalid token error" do + expect(service_call).to be_failure + expect(service_call.includes_error?(:token, :invalid)).to be(true) + expect(service_call.message).to eq(I18n.t("documents.collaboration_token.errors.invalid_previous_token")) + end + + it "neither revokes nor creates a token" do + previous_token + + expect { service_call }.not_to change(Doorkeeper::AccessToken, :count) + expect(Doorkeeper::AccessToken.where.not(revoked_at: nil)).to be_empty + end + end + + context "when the previous token is valid", freeze_time: DateTime.parse("2025-01-04T09:00:00Z") do + it "returns a new collaboration token" do + expect(service_call).to be_success + expect(service_call.result[:encrypted_token]).not_to eq(previous_token) + expect(service_call.result[:expires_in_seconds]).to eq(5.minutes.to_i) + + payload = decrypt_payload(service_call.result[:encrypted_token]) + expect(payload["resource_url"]).to eq(previous_token_result[:resource_url]) + expect(payload["expires_at"]).to eq("2025-01-04T09:05:00Z") + end + + it "creates a new access token for the user" do + previous_token + + expect { service_call }.to change(Doorkeeper::AccessToken, :count).by(1) + + new_access_token = access_token_for(service_call.result[:encrypted_token]) + expect(new_access_token.resource_owner_id).to eq(user.id) + expect(new_access_token).not_to eq(previous_access_token) + expect(new_access_token.revoked_at).to be_nil + end + + it "revokes the previous access token 30 seconds later" do + service_call + + previous_access_token.reload + expect(previous_access_token.revoked_at) + .to eq(Time.current + described_class::PREVIOUS_TOKEN_REVOCATION_DELAY) + expect(previous_access_token).not_to be_revoked + + travel(described_class::PREVIOUS_TOKEN_REVOCATION_DELAY + 1.second) + + expect(previous_access_token).to be_revoked + end + end + + shared_examples "rejects the already revoked previous token" do + before do + previous_access_token.update_column(:revoked_at, revoked_at) + end + + it "fails with an invalid token error" do + expect(service_call).to be_failure + expect(service_call.includes_error?(:token, :invalid)).to be(true) + expect(service_call.message).to eq(I18n.t("documents.collaboration_token.errors.invalid_previous_token")) + end + + it "neither creates a token nor changes the revocation" do + expect { service_call }.not_to change(Doorkeeper::AccessToken, :count) + expect(previous_access_token.reload.revoked_at).to eq(revoked_at) + end + end + + context "when the previous access token is already revoked" do + let(:revoked_at) { 1.minute.ago.change(usec: 0) } + + it_behaves_like "rejects the already revoked previous token" + end + + context "when the previous access token is already revoked, but still within the revocation delay" do + let(:revoked_at) { 10.seconds.from_now.change(usec: 0) } + + it_behaves_like "rejects the already revoked previous token" + end + + context "when the previous token is revoked concurrently" do + subject(:service_call) { service.call } + + let(:service) { described_class.new(user:, document:, previous_token:) } + let(:concurrent_revoked_at) { 10.seconds.from_now.change(usec: 0) } + + before do + previous_token + + allow(service).to receive(:previous_access_token).and_wrap_original do |original| + original.call.tap { |access_token| access_token.update_column(:revoked_at, concurrent_revoked_at) } + end + end + + it "fails with an invalid token error" do + expect(service_call).to be_failure + expect(service_call.includes_error?(:token, :invalid)).to be(true) + expect(service_call.message).to eq(I18n.t("documents.collaboration_token.errors.invalid_previous_token")) + end + + it "neither creates a token nor changes the concurrent revocation" do + expect { service_call }.not_to change(Doorkeeper::AccessToken, :count) + expect(previous_access_token.reload.revoked_at).to eq(concurrent_revoked_at) + end + end + + context "when real time collaboration is disabled" do + before do + previous_token + allow(document).to receive(:real_time_collaboration_available?).and_return(false) + end + + it "fails with a collaboration_disabled error and keeps the previous token" do + expect(service_call.includes_error?(:document, :collaboration_disabled)).to be(true) + expect(previous_access_token.reload.revoked_at).to be_nil + end + end + + context "when the previous token is expired" do + before do + previous_token + travel 10.minutes + end + + it "still creates a new token and revokes the previous one" do + expect(service_call).to be_success + expect(previous_access_token.reload.revoked_at).to be_present + end + end + + context "with a garbage token" do + let(:previous_token) { "garbage" } + + it_behaves_like "rejects the previous token" + end + + context "with a tampered token" do + let(:previous_token) { previous_token_result[:encrypted_token].reverse } + + it_behaves_like "rejects the previous token" + end + + context "with an encrypted payload that is not a JSON object" do + let(:previous_token) { Documents::OAuth::EncryptTokenService.new(token: "[1, 2]").call.result } + + it_behaves_like "rejects the previous token" + end + + context "with a token for another document" do + let(:other_document) { create(:document, project:) } + let(:previous_token) do + described_class.new(user:, document: other_document).call.result[:encrypted_token] + end + + it_behaves_like "rejects the previous token" + end + + context "with a token belonging to another user" do + let(:other_user) { create(:user, member_with_roles: { project => manage_role }) } + let(:previous_token) do + described_class.new(user: other_user, document:).call.result[:encrypted_token] + end + + it_behaves_like "rejects the previous token" + end + + context "with a token whose OAuth token was not issued to the Documents OAuth application" do + let(:foreign_access_token) { create(:oauth_access_token, resource_owner: user) } + let(:previous_token) do + encrypt(resource_url: previous_token_result[:resource_url], + oauth_token: foreign_access_token.plaintext_token, + expires_at: 5.minutes.from_now.iso8601, + readonly: true) + end + + it_behaves_like "rejects the previous token" + end + + context "with a token whose OAuth token does not exist" do + let(:previous_token) do + encrypt(resource_url: previous_token_result[:resource_url], + oauth_token: "does-not-exist", + expires_at: 5.minutes.from_now.iso8601, + readonly: true) + end + + it_behaves_like "rejects the previous token" + end + + context "when creating the new token fails" do + before do + previous_token + + allow(Documents::OAuth::EncryptTokenService) + .to receive(:new) + .and_return(instance_double(Documents::OAuth::EncryptTokenService, + call: ServiceResult.failure(errors: "Encryption failed"))) + allow(Rails.logger).to receive(:error) + end + + it "fails without an invalid token error" do + expect(service_call).to be_failure + expect(service_call.includes_error?(:token, :invalid)).to be(false) + end + + it "neither revokes the previous token nor keeps a new one" do + expect { service_call }.not_to change(Doorkeeper::AccessToken, :count) + expect(previous_access_token.reload.revoked_at).to be_nil + end + end + end end diff --git a/modules/documents/spec/support/shared/collaboration_token_endpoint.rb b/modules/documents/spec/support/shared/collaboration_token_endpoint.rb new file mode 100644 index 000000000000..631019c328ac --- /dev/null +++ b/modules/documents/spec/support/shared/collaboration_token_endpoint.rb @@ -0,0 +1,211 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +RSpec.shared_context "with a collaboration token endpoint setup" do + let(:project) { create(:project) } + let(:other_project) { create(:project) } + let(:document) { create(:document, project:) } + let(:role) { create(:project_role, permissions:) } + let(:permissions) { %i(view_documents) } + let(:current_user) { create(:user, member_with_roles: { project => role }) } + let(:hocuspocus_url) { "wss://hocuspocus.example.com" } + let(:hocuspocus_secret) { "test_secret_for_encryption" } + let(:collaboration_enabled) { true } + + before do + # Settings are stubbed here (instead of via `with_settings` metadata) so that single contexts + # can override them with a `let`. + allow(Setting).to receive_messages(real_time_text_collaboration_enabled?: collaboration_enabled, + collaborative_editing_hocuspocus_url: hocuspocus_url, + collaborative_editing_hocuspocus_secret: hocuspocus_secret) + end + + def post_collaboration_token_request + header "Content-Type", "application/json" + post path, request_body&.to_json + end + + def decrypt_collaboration_token(encrypted_token) + JSON.parse(Documents::OAuth::DecryptTokenService.new(token: encrypted_token).call.result) + end + + def collaboration_access_token_for(user) + Documents::OAuth::GenerateTokenService.new(user:).call.result + end +end + +# Expects the including spec to define `path` and `request_body` and to include +# the "with a collaboration token endpoint setup" context. +RSpec.shared_examples_for "a guarded collaboration token endpoint" do + def expect_api_error(status, identifier, message = nil) + expect(last_response).to have_http_status(status) + expect(last_response.body) + .to be_json_eql("urn:openproject-org:api:v3:errors:#{identifier}".to_json) + .at_path("errorIdentifier") + expect(last_response.body).to be_json_eql(message.to_json).at_path("message") if message + end + + context "when logged in" do + before do + login_as(current_user) + end + + context "when the document is not visible to the user" do + let(:document) { create(:document, project: other_project) } + + it "responds with 404" do + post_collaboration_token_request + + expect_api_error(404, "NotFound") + end + end + + # The visibility check already requires the view_documents permission, + # so a user lacking it can not distinguish the document from a non-existing one. + context "when the user lacks the view_documents permission in the document's project" do + let(:permissions) { %i(view_project) } + + it "responds with 404" do + post_collaboration_token_request + + expect_api_error(404, "NotFound") + end + end + + context "when the document is not collaborative" do + let(:document) { create(:document, project:, kind: "classic") } + + it "responds with 422" do + post_collaboration_token_request + + expect_api_error(422, "UnprocessableContent", + I18n.t("documents.collaboration_token.errors.not_collaborative")) + end + end + + context "when real-time collaboration is disabled" do + let(:collaboration_enabled) { false } + + it "responds with 422" do + post_collaboration_token_request + + expect_api_error(422, "UnprocessableContent", + I18n.t("documents.collaboration_token.errors.collaboration_disabled")) + end + end + + it "responds with 201 and forbids caching the response" do + post_collaboration_token_request + + expect(last_response).to have_http_status(:created) + expect(last_response.headers["Cache-Control"]).to eq("no-store") + end + + context "when the user may manage documents" do + let(:permissions) { %i(view_documents manage_documents) } + + it "responds with a writable token" do + post_collaboration_token_request + + expect(last_response).to have_http_status(:created) + + payload = decrypt_collaboration_token(JSON.parse(last_response.body)["token"]) + expect(payload["readonly"]).to be(false) + end + end + + context "when the user may only view documents" do + it "responds with a read-only token" do + post_collaboration_token_request + + expect(last_response).to have_http_status(:created) + + payload = decrypt_collaboration_token(JSON.parse(last_response.body)["token"]) + expect(payload["readonly"]).to be(true) + end + end + end + + context "when authenticated with a bearer token" do + before do + header "Authorization", "Bearer #{bearer_token}" + end + + context "when the token was issued to the collaboration server" do + let(:bearer_token) { collaboration_access_token_for(current_user).plaintext_token } + + it "responds with 403" do + post_collaboration_token_request + + expect_api_error(403, "MissingPermission", + I18n.t("documents.collaboration_token.errors.collaboration_token_not_allowed")) + end + end + + context "when the token is a regular OAuth access token" do + let(:bearer_token) { create(:oauth_access_token, resource_owner: current_user).plaintext_token } + + it "responds with 201" do + post_collaboration_token_request + + expect(last_response).to have_http_status(:created) + end + end + + context "when the token is an API key" do + let(:bearer_token) { create(:api_token, user: current_user).plain_value } + + it "responds with 201" do + post_collaboration_token_request + + expect(last_response).to have_http_status(:created) + end + end + end + + context "when authenticated with a session" do + let(:session_data) do + ActiveSupport::HashWithIndifferentAccess.new(user_id: current_user.id, updated_at: Time.current) + end + + before do + allow_any_instance_of(OpenProject::Authentication::Strategies::Warden::Session) # rubocop:disable RSpec/AnyInstance + .to receive(:session) + .and_return(session_data) + header "Sec-Fetch-Site", "same-origin" + end + + it "responds with 201" do + post_collaboration_token_request + + expect(last_response).to have_http_status(:created) + end + end +end From d4fbced434ff8d6c72c5f846ed14d2e835472a62 Mon Sep 17 00:00:00 2001 From: Judith Roth Date: Fri, 23 Oct 2026 16:56:45 +0200 Subject: [PATCH 2/9] [SE-664] Ensure anonymous users can not get collaboration tokens https://community.openproject.org/wp/SE-664 They can not connect anyway, so why give them tokens. Fixing the behaviour for anonymous users on instances that are public and have a public project with documents is out of scope of this PR though. --- .../v3/documents/collaboration_token_api.rb | 1 + .../shared/collaboration_token_endpoint.rb | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/modules/documents/lib/api/v3/documents/collaboration_token_api.rb b/modules/documents/lib/api/v3/documents/collaboration_token_api.rb index bffa1cc0a391..c8bf251332ed 100644 --- a/modules/documents/lib/api/v3/documents/collaboration_token_api.rb +++ b/modules/documents/lib/api/v3/documents/collaboration_token_api.rb @@ -43,6 +43,7 @@ def request_access_token end after_validation do + authorize_logged_in authorize_in_project(:view_documents, project: document.project) end diff --git a/modules/documents/spec/support/shared/collaboration_token_endpoint.rb b/modules/documents/spec/support/shared/collaboration_token_endpoint.rb index 631019c328ac..3845c14e0fbe 100644 --- a/modules/documents/spec/support/shared/collaboration_token_endpoint.rb +++ b/modules/documents/spec/support/shared/collaboration_token_endpoint.rb @@ -190,6 +190,24 @@ def expect_api_error(status, identifier, message = nil) end end + context "when anonymous with the view_documents permission in a public project", + with_settings: { login_required: false } do + let(:project) { create(:public_project) } + + before do + create(:anonymous_role, permissions: %i(view_documents)) + login_as User.anonymous + end + + it "responds with 403 and does not create a token" do + request_body + + expect { post_collaboration_token_request }.not_to change(Doorkeeper::AccessToken, :count) + + expect_api_error(403, "MissingPermission") + end + end + context "when authenticated with a session" do let(:session_data) do ActiveSupport::HashWithIndifferentAccess.new(user_id: current_user.id, updated_at: Time.current) From eed5d5534700258f41783928d485cf9d0bdf6a79 Mon Sep 17 00:00:00 2001 From: Judith Roth Date: Thu, 22 Oct 2026 17:14:34 +0200 Subject: [PATCH 3/9] [SE-664] Add feature flag for collaboration token API endpoint https://community.openproject.org/wp/SE-664 --- .../components/schemas/document_model.yml | 3 +- .../paths/document_collaboration_token.yml | 5 ++- .../v3/documents/collaboration_token_api.rb | 4 ++ .../api/v3/documents/document_representer.rb | 6 ++- .../lib/open_project/documents/engine.rb | 7 ++++ .../document_representer_rendering_spec.rb | 20 +++++++++- .../collaboration_token_resource_spec.rb | 37 ++++++++++++++++++- 7 files changed, 77 insertions(+), 5 deletions(-) diff --git a/docs/api/apiv3/components/schemas/document_model.yml b/docs/api/apiv3/components/schemas/document_model.yml index 42acf192650e..c84feefba619 100644 --- a/docs/api/apiv3/components/schemas/document_model.yml +++ b/docs/api/apiv3/components/schemas/document_model.yml @@ -61,7 +61,8 @@ properties: **Permission**: view documents - The document is collaborative and real-time collaboration is enabled. + The document is collaborative, real-time collaboration is enabled and the feature flag + `documents_collaboration_token_api` is active. readOnly: true example: _type: Document diff --git a/docs/api/apiv3/paths/document_collaboration_token.yml b/docs/api/apiv3/paths/document_collaboration_token.yml index f54cdab7bc9c..c26c68bff85e 100644 --- a/docs/api/apiv3/paths/document_collaboration_token.yml +++ b/docs/api/apiv3/paths/document_collaboration_token.yml @@ -82,7 +82,8 @@ post: errorIdentifier: urn:openproject-org:api:v3:errors:NotFound message: The requested resource could not be found. description: |- - Returned if the document does not exist or if the user does not have permission to view it. + Returned if the document does not exist, if the user does not have permission to view it or if the + feature flag `documents_collaboration_token_api` is off. **Required permission** `view documents` in the project the document belongs to headers: {} @@ -131,6 +132,8 @@ post: description: |- Creates a short-lived token for the real-time collaboration (Hocuspocus) server for the document. + The endpoint is only available when the feature flag `documents_collaboration_token_api` is active. + The token is issued for the requesting user. Users who may only view documents receive a read-only token. Requests authenticated with a token issued to the collaboration server are rejected. diff --git a/modules/documents/lib/api/v3/documents/collaboration_token_api.rb b/modules/documents/lib/api/v3/documents/collaboration_token_api.rb index c8bf251332ed..a599f542c181 100644 --- a/modules/documents/lib/api/v3/documents/collaboration_token_api.rb +++ b/modules/documents/lib/api/v3/documents/collaboration_token_api.rb @@ -42,6 +42,10 @@ def request_access_token end end + before do + guard_feature_flag(:documents_collaboration_token_api) + end + after_validation do authorize_logged_in authorize_in_project(:view_documents, project: document.project) diff --git a/modules/documents/lib/api/v3/documents/document_representer.rb b/modules/documents/lib/api/v3/documents/document_representer.rb index d8e5549a2438..99d7a2744661 100644 --- a/modules/documents/lib/api/v3/documents/document_representer.rb +++ b/modules/documents/lib/api/v3/documents/document_representer.rb @@ -39,7 +39,10 @@ class DocumentRepresenter < ::API::Decorators::Single include ::API::V3::Attachments::AttachableRepresenterMixin cached_representer key_parts: %i(project), - dependencies: -> { Setting.real_time_text_collaboration_enabled? }, + dependencies: -> { + [Setting.real_time_text_collaboration_enabled?, + OpenProject::FeatureDecisions.documents_collaboration_token_api_active?] + }, disabled: false self_link title_getter: ->(*) { represented.title } @@ -54,6 +57,7 @@ class DocumentRepresenter < ::API::Decorators::Single link :createCollaborationToken, cache_if: -> { current_user.allowed_in_project?(:view_documents, represented.project) } do + next unless OpenProject::FeatureDecisions.documents_collaboration_token_api_active? next unless represented.real_time_collaboration_available? { diff --git a/modules/documents/lib/open_project/documents/engine.rb b/modules/documents/lib/open_project/documents/engine.rb index 5b4c6aca7f48..f3bd75979538 100644 --- a/modules/documents/lib/open_project/documents/engine.rb +++ b/modules/documents/lib/open_project/documents/engine.rb @@ -33,6 +33,13 @@ class Engine < ::Rails::Engine include OpenProject::Plugins::ActsAsOpEngine + initializer "openproject_documents.feature_decisions" do + OpenProject::FeatureDecisions.add :documents_collaboration_token_api, + description: "Enables the API endpoint for creating real-time collaboration " \ + "tokens for documents (used by the mobile app). " \ + "See SE-664 for details." + end + register "openproject-documents", author_url: "http://www.openproject.org", bundled: true do diff --git a/modules/documents/spec/api/v3/documents/document_representer_rendering_spec.rb b/modules/documents/spec/api/v3/documents/document_representer_rendering_spec.rb index aaf88b3534b9..cc095960e905 100644 --- a/modules/documents/spec/api/v3/documents/document_representer_rendering_spec.rb +++ b/modules/documents/spec/api/v3/documents/document_representer_rendering_spec.rb @@ -85,7 +85,9 @@ end end - describe "createCollaborationToken", with_settings: { real_time_text_collaboration_enabled: true } do + describe "createCollaborationToken", + with_flag: { documents_collaboration_token_api: true }, + with_settings: { real_time_text_collaboration_enabled: true } do let(:link) { :createCollaborationToken } let(:document_permissions) { %i(view_documents) } @@ -136,6 +138,22 @@ it_behaves_like "has no link" end + context "when the feature flag is inactive", with_flag: { documents_collaboration_token_api: false } do + it_behaves_like "has no link" + end + + context "when the feature flag is deactivated after the representer has been cached" do + it "no longer renders the link" do + expect(described_class.create(document, current_user:, embed_links:).to_json) + .to have_json_path("_links/createCollaborationToken") + + allow(OpenProject::FeatureDecisions).to receive(:documents_collaboration_token_api_active?).and_return(false) + + expect(described_class.create(document, current_user:, embed_links:).to_json) + .not_to have_json_path("_links/createCollaborationToken") + end + end + context "when real-time collaboration is disabled after the representer has been cached" do it "no longer renders the link" do expect(described_class.create(document, current_user:, embed_links:).to_json) diff --git a/modules/documents/spec/requests/api/v3/documents/collaboration_token_resource_spec.rb b/modules/documents/spec/requests/api/v3/documents/collaboration_token_resource_spec.rb index 402fd7f069a5..a172ec189de5 100644 --- a/modules/documents/spec/requests/api/v3/documents/collaboration_token_resource_spec.rb +++ b/modules/documents/spec/requests/api/v3/documents/collaboration_token_resource_spec.rb @@ -32,7 +32,7 @@ require "rack/test" require_relative "../../../../support/shared/collaboration_token_endpoint" -RSpec.describe "API v3 document collaboration token resource" do +RSpec.describe "API v3 document collaboration token resource", with_flag: { documents_collaboration_token_api: true } do include Rack::Test::Methods include API::V3::Utilities::PathHelper @@ -44,6 +44,41 @@ describe "POST /api/v3/documents/:id/collaboration_token" do it_behaves_like "a guarded collaboration token endpoint" + context "when the feature flag is inactive", with_flag: { documents_collaboration_token_api: false } do + context "when logged in" do + before do + login_as(current_user) + end + + it "responds with 404 and does not create a token" do + expect { post_collaboration_token_request }.not_to change(Doorkeeper::AccessToken, :count) + + expect(last_response).to have_http_status(:not_found) + expect(last_response.body) + .to be_json_eql("urn:openproject-org:api:v3:errors:NotFound".to_json) + .at_path("errorIdentifier") + end + + context "with an invalid document id" do + let(:path) { "#{api_v3_paths.documents}/invalid/collaboration_token" } + + it "responds with 404" do + post_collaboration_token_request + + expect(last_response).to have_http_status(:not_found) + end + end + end + + context "when not logged in", with_settings: { login_required: true } do + it "responds with 404" do + post_collaboration_token_request + + expect(last_response).to have_http_status(:not_found) + end + end + end + context "when logged in with the view_documents permission", freeze_time: DateTime.parse("2025-01-04T09:00:00Z") do let(:response_body) { JSON.parse(last_response.body) } From e78d0c69701dcc9e51862a47ef894bbf90793d02 Mon Sep 17 00:00:00 2001 From: Judith Roth Date: Fri, 23 Oct 2026 17:16:04 +0200 Subject: [PATCH 4/9] Revert "[SE-664] Add feature flag for collaboration token API endpoint" This reverts commit eed5d5534700258f41783928d485cf9d0bdf6a79. --- .../components/schemas/document_model.yml | 3 +- .../paths/document_collaboration_token.yml | 5 +-- .../v3/documents/collaboration_token_api.rb | 4 -- .../api/v3/documents/document_representer.rb | 6 +-- .../lib/open_project/documents/engine.rb | 7 ---- .../document_representer_rendering_spec.rb | 20 +--------- .../collaboration_token_resource_spec.rb | 37 +------------------ 7 files changed, 5 insertions(+), 77 deletions(-) diff --git a/docs/api/apiv3/components/schemas/document_model.yml b/docs/api/apiv3/components/schemas/document_model.yml index c84feefba619..42acf192650e 100644 --- a/docs/api/apiv3/components/schemas/document_model.yml +++ b/docs/api/apiv3/components/schemas/document_model.yml @@ -61,8 +61,7 @@ properties: **Permission**: view documents - The document is collaborative, real-time collaboration is enabled and the feature flag - `documents_collaboration_token_api` is active. + The document is collaborative and real-time collaboration is enabled. readOnly: true example: _type: Document diff --git a/docs/api/apiv3/paths/document_collaboration_token.yml b/docs/api/apiv3/paths/document_collaboration_token.yml index c26c68bff85e..f54cdab7bc9c 100644 --- a/docs/api/apiv3/paths/document_collaboration_token.yml +++ b/docs/api/apiv3/paths/document_collaboration_token.yml @@ -82,8 +82,7 @@ post: errorIdentifier: urn:openproject-org:api:v3:errors:NotFound message: The requested resource could not be found. description: |- - Returned if the document does not exist, if the user does not have permission to view it or if the - feature flag `documents_collaboration_token_api` is off. + Returned if the document does not exist or if the user does not have permission to view it. **Required permission** `view documents` in the project the document belongs to headers: {} @@ -132,8 +131,6 @@ post: description: |- Creates a short-lived token for the real-time collaboration (Hocuspocus) server for the document. - The endpoint is only available when the feature flag `documents_collaboration_token_api` is active. - The token is issued for the requesting user. Users who may only view documents receive a read-only token. Requests authenticated with a token issued to the collaboration server are rejected. diff --git a/modules/documents/lib/api/v3/documents/collaboration_token_api.rb b/modules/documents/lib/api/v3/documents/collaboration_token_api.rb index a599f542c181..c8bf251332ed 100644 --- a/modules/documents/lib/api/v3/documents/collaboration_token_api.rb +++ b/modules/documents/lib/api/v3/documents/collaboration_token_api.rb @@ -42,10 +42,6 @@ def request_access_token end end - before do - guard_feature_flag(:documents_collaboration_token_api) - end - after_validation do authorize_logged_in authorize_in_project(:view_documents, project: document.project) diff --git a/modules/documents/lib/api/v3/documents/document_representer.rb b/modules/documents/lib/api/v3/documents/document_representer.rb index 99d7a2744661..d8e5549a2438 100644 --- a/modules/documents/lib/api/v3/documents/document_representer.rb +++ b/modules/documents/lib/api/v3/documents/document_representer.rb @@ -39,10 +39,7 @@ class DocumentRepresenter < ::API::Decorators::Single include ::API::V3::Attachments::AttachableRepresenterMixin cached_representer key_parts: %i(project), - dependencies: -> { - [Setting.real_time_text_collaboration_enabled?, - OpenProject::FeatureDecisions.documents_collaboration_token_api_active?] - }, + dependencies: -> { Setting.real_time_text_collaboration_enabled? }, disabled: false self_link title_getter: ->(*) { represented.title } @@ -57,7 +54,6 @@ class DocumentRepresenter < ::API::Decorators::Single link :createCollaborationToken, cache_if: -> { current_user.allowed_in_project?(:view_documents, represented.project) } do - next unless OpenProject::FeatureDecisions.documents_collaboration_token_api_active? next unless represented.real_time_collaboration_available? { diff --git a/modules/documents/lib/open_project/documents/engine.rb b/modules/documents/lib/open_project/documents/engine.rb index f3bd75979538..5b4c6aca7f48 100644 --- a/modules/documents/lib/open_project/documents/engine.rb +++ b/modules/documents/lib/open_project/documents/engine.rb @@ -33,13 +33,6 @@ class Engine < ::Rails::Engine include OpenProject::Plugins::ActsAsOpEngine - initializer "openproject_documents.feature_decisions" do - OpenProject::FeatureDecisions.add :documents_collaboration_token_api, - description: "Enables the API endpoint for creating real-time collaboration " \ - "tokens for documents (used by the mobile app). " \ - "See SE-664 for details." - end - register "openproject-documents", author_url: "http://www.openproject.org", bundled: true do diff --git a/modules/documents/spec/api/v3/documents/document_representer_rendering_spec.rb b/modules/documents/spec/api/v3/documents/document_representer_rendering_spec.rb index cc095960e905..aaf88b3534b9 100644 --- a/modules/documents/spec/api/v3/documents/document_representer_rendering_spec.rb +++ b/modules/documents/spec/api/v3/documents/document_representer_rendering_spec.rb @@ -85,9 +85,7 @@ end end - describe "createCollaborationToken", - with_flag: { documents_collaboration_token_api: true }, - with_settings: { real_time_text_collaboration_enabled: true } do + describe "createCollaborationToken", with_settings: { real_time_text_collaboration_enabled: true } do let(:link) { :createCollaborationToken } let(:document_permissions) { %i(view_documents) } @@ -138,22 +136,6 @@ it_behaves_like "has no link" end - context "when the feature flag is inactive", with_flag: { documents_collaboration_token_api: false } do - it_behaves_like "has no link" - end - - context "when the feature flag is deactivated after the representer has been cached" do - it "no longer renders the link" do - expect(described_class.create(document, current_user:, embed_links:).to_json) - .to have_json_path("_links/createCollaborationToken") - - allow(OpenProject::FeatureDecisions).to receive(:documents_collaboration_token_api_active?).and_return(false) - - expect(described_class.create(document, current_user:, embed_links:).to_json) - .not_to have_json_path("_links/createCollaborationToken") - end - end - context "when real-time collaboration is disabled after the representer has been cached" do it "no longer renders the link" do expect(described_class.create(document, current_user:, embed_links:).to_json) diff --git a/modules/documents/spec/requests/api/v3/documents/collaboration_token_resource_spec.rb b/modules/documents/spec/requests/api/v3/documents/collaboration_token_resource_spec.rb index a172ec189de5..402fd7f069a5 100644 --- a/modules/documents/spec/requests/api/v3/documents/collaboration_token_resource_spec.rb +++ b/modules/documents/spec/requests/api/v3/documents/collaboration_token_resource_spec.rb @@ -32,7 +32,7 @@ require "rack/test" require_relative "../../../../support/shared/collaboration_token_endpoint" -RSpec.describe "API v3 document collaboration token resource", with_flag: { documents_collaboration_token_api: true } do +RSpec.describe "API v3 document collaboration token resource" do include Rack::Test::Methods include API::V3::Utilities::PathHelper @@ -44,41 +44,6 @@ describe "POST /api/v3/documents/:id/collaboration_token" do it_behaves_like "a guarded collaboration token endpoint" - context "when the feature flag is inactive", with_flag: { documents_collaboration_token_api: false } do - context "when logged in" do - before do - login_as(current_user) - end - - it "responds with 404 and does not create a token" do - expect { post_collaboration_token_request }.not_to change(Doorkeeper::AccessToken, :count) - - expect(last_response).to have_http_status(:not_found) - expect(last_response.body) - .to be_json_eql("urn:openproject-org:api:v3:errors:NotFound".to_json) - .at_path("errorIdentifier") - end - - context "with an invalid document id" do - let(:path) { "#{api_v3_paths.documents}/invalid/collaboration_token" } - - it "responds with 404" do - post_collaboration_token_request - - expect(last_response).to have_http_status(:not_found) - end - end - end - - context "when not logged in", with_settings: { login_required: true } do - it "responds with 404" do - post_collaboration_token_request - - expect(last_response).to have_http_status(:not_found) - end - end - end - context "when logged in with the view_documents permission", freeze_time: DateTime.parse("2025-01-04T09:00:00Z") do let(:response_body) { JSON.parse(last_response.body) } From 1bfcda190080b98231479b29661ac6870872ff7e Mon Sep 17 00:00:00 2001 From: Judith Roth Date: Thu, 8 Oct 2026 22:12:05 +0200 Subject: [PATCH 5/9] [SE-664] Restrict collaboration tokens to an edit_documents OAuth scope https://community.openproject.org/wp/SE-664 - Collaboration tokens only carry `edit_documents`, accepted solely by GET/PATCH /api/v3/documents/:id (`route_setting :oauth_scopes`). An existing Documents OAuth application is switched to that scope. - All other APIv3 endpoints answer 403 `InsufficientScope`. - The scope is internal: it's hidden from OAuth apps and metadata. - It replaces the application-uid check. Co-Authored-By: Claude Opus 5.5 --- .../oauth/applications/base_contract.rb | 9 ++ app/controllers/oauth_metadata_controller.rb | 6 +- app/views/oauth/applications/_form.html.erb | 5 +- config/locales/en.yml | 4 + .../paths/document_collaboration_token.yml | 10 +- docs/api/apiv3/tags/documents.yml | 6 +- lib/api/errors/insufficient_scope.rb | 46 ++++++ lib/api/root_api.rb | 26 +++ lib_static/open_project/authentication.rb | 36 +++++ .../strategies/warden/doorkeeper_oauth.rb | 13 +- .../documents/app/services/documents/oauth.rb | 35 ++++ .../oauth/ensure_application_service.rb | 24 +-- .../documents/oauth/generate_token_service.rb | 2 +- .../oauth/token_with_metadata_service.rb | 16 +- modules/documents/config/locales/en.yml | 5 +- .../v3/documents/collaboration_token_api.rb | 20 +-- .../lib/api/v3/documents/documents_api.rb | 2 + .../lib/open_project/documents/engine.rb | 8 + .../collaboration_token_resource_spec.rb | 3 +- .../v3/documents/edit_documents_scope_spec.rb | 151 ++++++++++++++++++ .../oauth/ensure_application_service_spec.rb | 28 +--- .../oauth/generate_token_service_spec.rb | 6 + .../oauth/token_with_metadata_service_spec.rb | 23 +-- .../shared/collaboration_token_endpoint.rb | 9 +- .../oauth/applications/base_contract_spec.rb | 39 +++++ .../oauth/applications_controller_spec.rb | 15 ++ .../warden/doorkeeper_oauth_spec.rb | 94 +++++++++++ ...auth_authorization_server_metadata_spec.rb | 10 ++ 28 files changed, 551 insertions(+), 100 deletions(-) create mode 100644 lib/api/errors/insufficient_scope.rb create mode 100644 modules/documents/app/services/documents/oauth.rb create mode 100644 modules/documents/spec/requests/api/v3/documents/edit_documents_scope_spec.rb create mode 100644 spec/lib/open_project/authentication/strategies/warden/doorkeeper_oauth_spec.rb diff --git a/app/contracts/oauth/applications/base_contract.rb b/app/contracts/oauth/applications/base_contract.rb index 2d54af5f4f94..68c66f0a383e 100644 --- a/app/contracts/oauth/applications/base_contract.rb +++ b/app/contracts/oauth/applications/base_contract.rb @@ -37,6 +37,7 @@ def self.model validate :validate_client_credential_user validate :validate_integration validate :validate_admin_only + validate :validate_internal_scopes attribute :enabled attribute :name @@ -57,6 +58,14 @@ def validate_admin_only errors.add :base, :error_unauthorized unless user.admin? end + def validate_internal_scopes + return if user.is_a?(SystemUser) || !model.will_save_change_to_scopes? + + if model.scopes.to_a.intersect?(OpenProject::Authentication.internal_oauth_scopes) + errors.add :scopes, :inclusion + end + end + def validate_integration both = model.integration_id.present? && model.integration_type.present? none = model.integration_id.nil? && model.integration_type.nil? diff --git a/app/controllers/oauth_metadata_controller.rb b/app/controllers/oauth_metadata_controller.rb index 283637a04f8c..3dd9cac6af4a 100644 --- a/app/controllers/oauth_metadata_controller.rb +++ b/app/controllers/oauth_metadata_controller.rb @@ -40,7 +40,7 @@ def authorization_server authorization_endpoint: oauth_authorization_url, token_endpoint: oauth_token_url, introspection_endpoint: oauth_introspect_url, - scopes_supported: Doorkeeper.configuration.scopes.to_a, + scopes_supported: supported_scopes, response_types_supported: response_types(Doorkeeper.configuration.grant_flows), grant_types_supported: grant_types, service_documentation: OpenProject::Static::Links.url_for(:oauth_applications) @@ -60,6 +60,10 @@ def protected_resource private + def supported_scopes + Doorkeeper.configuration.scopes.to_a - OpenProject::Authentication.internal_oauth_scopes + end + def response_types(grant_types) grant_types.filter_map do |grant| case grant diff --git a/app/views/oauth/applications/_form.html.erb b/app/views/oauth/applications/_form.html.erb index 9f9e5a27d804..a56cd84e2cd0 100644 --- a/app/views/oauth/applications/_form.html.erb +++ b/app/views/oauth/applications/_form.html.erb @@ -51,10 +51,11 @@ See COPYRIGHT and LICENSE files for more details. - <% if Doorkeeper.configuration.scopes.all.length > 1 %> + <% selectable_scopes = Doorkeeper.configuration.scopes.to_a - OpenProject::Authentication.internal_oauth_scopes %> + <% if selectable_scopes.length > 1 %>
- <% Doorkeeper.configuration.scopes.each do |scope| %> + <% selectable_scopes.each do |scope| %>