diff --git a/config/locales/en.yml b/config/locales/en.yml
index a657733ee090..16648644b917 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -2328,6 +2328,12 @@ en:
date: "YYYY-MM-DD (ISO 8601 date only)"
datetime: "YYYY-MM-DDThh:mm:ss[.lll][+hh:mm] (any compatible ISO 8601 datetime)"
duration: "ISO 8601 duration"
+ insufficient_scope: >-
+ The access token used for this request does not grant access to this resource.
+ It only grants the scope(s) %{granted}, but this request requires the scope '%{required}'.
+ insufficient_scope_any: >-
+ The access token used for this request does not grant access to this resource.
+ It only grants the scope(s) %{granted}, but this request requires one of the scopes %{accepted}.
invalid_content_type: "Expected CONTENT-TYPE to be '%{content_type}' but got '%{actual}'."
invalid_format: "Invalid format for property '%{property}': Expected format like '%{expected_format}', but got '%{actual}'."
invalid_json: "The request could not be parsed as JSON."
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..63e20b3e1e9b
--- /dev/null
+++ b/docs/api/apiv3/paths/document_collaboration_token.yml
@@ -0,0 +1,147 @@
+# /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:InsufficientScope
+ message: The access token used for this request does not grant access to this resource. It only
+ grants the scope(s) 'op:resource:documents:edit', but this request requires the scope 'api_v3'.
+ description: |-
+ Returned if the request is authenticated with an OAuth access token that lacks the `api_v3` scope,
+ e.g. the token inside a collaboration token. 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..07b6be450e42 100644
--- a/docs/api/apiv3/tags/documents.yml
+++ b/docs/api/apiv3/tags/documents.yml
@@ -2,11 +2,36 @@
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:
+
+ - The OAuth access token inside a collaboration token only carries the internal scope
+ `op:resource:documents:edit`. It is valid solely for `GET` and `PATCH /api/v3/documents/{id}`.
+ Every other request is rejected with `InsufficientScope`. In particular, it cannot create 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/docs/development/concepts/README.md b/docs/development/concepts/README.md
index 784312f9d056..49d988f3baa8 100644
--- a/docs/development/concepts/README.md
+++ b/docs/development/concepts/README.md
@@ -18,6 +18,7 @@ Please choose an area that you would like to read about:
| [State management](state-management) | How does the frontend handle state and react to changes? |
| [HAL resources](hal-resources) | What are HAL resources and how are they used in the frontend? |
| [Permissions](permissions) | How are roles and permissions handled in OpenProject code? |
+| [OAuth scopes](oauth-scopes) | How are OAuth scopes named and registered? |
| [Translations](translations) | How are translations used and built? |
| [Resource schemas](resource-schemas) | What is a schema and how is it tied to an editable resource? |
| [Resource changesets](resource-changesets) | How is change tracked to resources in the frontend? How to save the changes. |
diff --git a/docs/development/concepts/oauth-scopes/README.md b/docs/development/concepts/oauth-scopes/README.md
new file mode 100644
index 000000000000..11d0de756adc
--- /dev/null
+++ b/docs/development/concepts/oauth-scopes/README.md
@@ -0,0 +1,83 @@
+---
+sidebar_navigation:
+ title: OAuth scopes
+description: Get an overview of how OAuth scopes are named and registered in the OpenProject application code
+keywords: OAuth, scopes, access tokens, authentication
+---
+
+# Development concept: OAuth scopes
+
+OAuth access tokens carry scopes that limit what the token can be used for. Besides the general scopes like `api_v3`, modules can define scopes that grant access to a few selected endpoints only, e.g. to let a client edit documents without granting it the whole API.
+
+## Key takeaways
+
+_New OAuth scopes in OpenProject..._
+
+- are named `op:
::`, e.g. `op:resource:documents:edit`
+- are registered with `OpenProject::Authentication.register_restricted_oauth_scope`, which validates the name at boot
+- are internal: they can't be assigned to OAuth applications and don't show up in the OAuth metadata
+- are only accepted on endpoints that opt in with `route_setting :oauth_scopes`
+- differ from the legacy scopes `api_v3`, `scim_v2`, `mcp` and `bcf_v2_1`, which keep their names
+
+## Format
+
+A scope consists of four lowercase segments separated by `:`:
+
+| Segment | Allowed values |
+|--------------|----------------------------------------------------------------------------|
+| `op` | Always `op`, the OpenProject namespace. |
+| `` | One of `OpenProject::Authentication::OAUTH_SCOPE_KINDS` (`resource`). |
+| `` | One of `OpenProject::Authentication::OAUTH_SCOPE_RESOURCES` (`documents`). |
+| `` | Free, but a lowercase segment matching `/\A[a-z][a-z0-9_]*\z/`. |
+
+The allowed kinds and resources are defined in [`OpenProject::Authentication`](https://github.com/opf/openproject/blob/dev/lib_static/open_project/authentication.rb). Extending them is a deliberate change to core. Actions differ a lot per endpoint, so they aren't restricted to a fixed list.
+
+A scope that doesn't match this format raises an `ArgumentError` on registration, so the application fails to boot.
+
+## Why the `op` prefix
+
+OpenProject can accept JWTs issued by external identity providers and reads their `scope` claim (see the [`JwtOidc` strategy](https://github.com/opf/openproject/blob/dev/lib_static/open_project/authentication/strategies/warden/jwt_oidc.rb)). Such an identity provider usually issues scopes for several services. The `op` prefix keeps OpenProject's scopes from colliding with the scopes of other services.
+
+## Restricted, internal scopes
+
+A scope registered with `register_restricted_oauth_scope` is restricted to a Warden scope, e.g. `api_v3`. A token carrying it authenticates for that Warden scope, but only grants access to the endpoints that explicitly accept it. All other endpoints of the API answer with `403` and the `InsufficientScope` error.
+
+Such scopes are always internal. OpenProject issues tokens with them itself, e.g. the documents module through its own OAuth application. Administrators can't assign them to OAuth applications, and the OAuth metadata doesn't list them.
+
+## How to add a scope
+
+1. If the resource is new, add it to `OAUTH_SCOPE_RESOURCES` in `OpenProject::Authentication`.
+2. Register the scope in the `to_prepare` block of your module's engine:
+
+ ```ruby
+ config.to_prepare do
+ OpenProject::Authentication.register_restricted_oauth_scope("op:resource:documents:edit",
+ restricted_to: OpenProject::Authentication::Scope::API_V3)
+ end
+ ```
+
+ Registering the same scope again (e.g. on code reload in development) is harmless.
+
+3. Opt the endpoints in that should accept the scope:
+
+ ```ruby
+ route_setting :oauth_scopes, ["op:resource:documents:edit"]
+ get do
+ # ...
+ end
+ ```
+
+See the [documents engine](https://github.com/opf/openproject/blob/dev/modules/documents/lib/open_project/documents/engine.rb) and the [documents API](https://github.com/opf/openproject/blob/dev/modules/documents/lib/api/v3/documents/documents_api.rb) for an example.
+
+## Legacy scopes
+
+The following scopes predate this convention and don't follow the format:
+
+| Scope | Registered in |
+|------------|---------------------------------------------------|
+| `api_v3` | `config/initializers/doorkeeper.rb` |
+| `scim_v2` | `config/initializers/doorkeeper.rb` |
+| `mcp` | `config/initializers/doorkeeper.rb` |
+| `bcf_v2_1` | `modules/bim/lib/open_project/bim/engine.rb` |
+
+They match the names of Warden scopes (`OpenProject::Authentication::Scope`) and are used by existing clients, so they keep their names. They are assignable to OAuth applications. Don't add new scopes like them.
diff --git a/lib/api/errors/insufficient_scope.rb b/lib/api/errors/insufficient_scope.rb
new file mode 100644
index 000000000000..63307b5c436d
--- /dev/null
+++ b/lib/api/errors/insufficient_scope.rb
@@ -0,0 +1,57 @@
+# 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 Errors
+ class InsufficientScope < ErrorBase
+ identifier "InsufficientScope"
+ code 403
+
+ def initialize(granted_scopes:, accepted_scopes:)
+ granted = format_scopes(granted_scopes)
+
+ message =
+ if accepted_scopes.one?
+ I18n.t("api_v3.errors.insufficient_scope", granted:, required: accepted_scopes.first)
+ else
+ I18n.t("api_v3.errors.insufficient_scope_any", granted:, accepted: format_scopes(accepted_scopes))
+ end
+
+ super(message)
+ end
+
+ private
+
+ def format_scopes(scopes)
+ scopes.map { |scope| "'#{scope}'" }.join(", ")
+ end
+ end
+ end
+end
diff --git a/lib/api/root_api.rb b/lib/api/root_api.rb
index 00a94e1b3689..0af728cabfa8 100644
--- a/lib/api/root_api.rb
+++ b/lib/api/root_api.rb
@@ -71,6 +71,8 @@ def request_body
def authenticate
User.current = warden.authenticate! scope: authentication_scope
+ enforce_oauth_scope
+
if Setting.login_required? && !logged_in? && !allowed_unauthenticated_route?
raise ::API::Errors::Unauthenticated
end
@@ -80,6 +82,21 @@ def allowed_unauthenticated_route?
false
end
+ # OAuth access tokens lacking the authentication scope (e.g. `api_v3`) were accepted for a restricted
+ # scope and only grant access to endpoints opting into it via `route_setting :oauth_scopes`.
+ def enforce_oauth_scope
+ access_token = env[OpenProject::Authentication::Strategies::Warden::DoorkeeperOAuth::ACCESS_TOKEN_ENV_KEY]
+ return if access_token.nil?
+ return if access_token.includes_scope?(*accepted_oauth_scopes)
+
+ raise ::API::Errors::InsufficientScope.new(granted_scopes: access_token.scopes.to_a,
+ accepted_scopes: accepted_oauth_scopes)
+ end
+
+ def accepted_oauth_scopes
+ [authentication_scope.to_s, *Array(route.settings[:oauth_scopes]).map(&:to_s)]
+ end
+
def set_localization
SetLocalizationService.new(User.current, env["HTTP_ACCEPT_LANGUAGE"]).call
end
@@ -284,6 +301,17 @@ def self.auth_headers
end
end
+ def self.insufficient_scope_headers
+ lambda do
+ header = OpenProject::Authentication::WWWAuthenticate.response_header(env:,
+ scope: authentication_scope,
+ oauth_scopes: accepted_oauth_scopes,
+ error: "insufficient_scope")
+
+ { "WWW-Authenticate" => header }
+ end
+ end
+
def self.error_representer(klass, content_type)
# Have the vars available in the instances via helpers.
helpers do
@@ -309,6 +337,7 @@ def self.authentication_scope(sym)
error_response MultiJSON::ParseError, ::API::Errors::ParseError
error_response ::API::Errors::Unauthenticated, headers: auth_headers, log: false
+ error_response ::API::Errors::InsufficientScope, headers: insufficient_scope_headers, log: false
error_response ::API::Errors::ErrorBase, rescue_subclasses: true, log: false
# Handle grape validation errors
diff --git a/lib_static/open_project/authentication.rb b/lib_static/open_project/authentication.rb
index 9dc1430e80e6..c168cb53da83 100644
--- a/lib_static/open_project/authentication.rb
+++ b/lib_static/open_project/authentication.rb
@@ -33,6 +33,14 @@ module OpenProject
##
# OpenProject uses Warden strategies for request authentication.
module Authentication
+ ##
+ # Allowed segments of OpenProject's OAuth scopes, formatted as `op:::`.
+ # The `op` prefix keeps them apart from other services' scopes on a shared identity provider.
+ # Extending the lists is a deliberate core change. See docs/development/concepts/oauth-scopes.
+ OAUTH_SCOPE_KINDS = %w[resource].freeze
+ OAUTH_SCOPE_RESOURCES = %w[documents].freeze
+ OAUTH_SCOPE_ACTION_FORMAT = /\A[a-z][a-z0-9_]*\z/
+
class << self
##
# Registers a given Warden strategy to be used for authentication.
@@ -65,7 +73,9 @@ def add_strategy(name, clazz, auth_scheme)
# @yieldparam [Set] strategies The strategies currently used by this scope. May be empty.
# @yieldreturn [Set] The strategies to be used by this scope.
def update_strategies(scope, opts = {}, &)
- raise ArgumentError, "invalid scope: #{scope}" unless Scope.values.include? scope
+ unless Scope.values.include?(scope) # rubocop:disable Performance/InefficientHashSearch
+ raise ArgumentError, "invalid scope: #{scope}"
+ end
config = Manager.scope_config scope
config.update!(opts, &)
@@ -86,6 +96,59 @@ def update_strategies(scope, opts = {}, &)
def handle_failure(scope: nil, &block)
Manager.failure_handlers[scope] = block
end
+
+ ##
+ # Registers an OAuth scope that authenticates for the given Warden scope, but only grants
+ # access to endpoints opting in via `route_setting :oauth_scopes`. The scope is always
+ # internal, i.e. it can't be assigned to OAuth applications.
+ # Raises an ArgumentError on an invalid format or an unknown Warden scope.
+ # See docs/development/concepts/oauth-scopes.
+ #
+ # @param [String] oauth_scope The OAuth scope, formatted as `op:::`.
+ # @param [Symbol] restricted_to The Warden scope (see `OpenProject::Authentication::Scope`).
+ def register_restricted_oauth_scope(oauth_scope, restricted_to:)
+ oauth_scope = oauth_scope.to_s
+ validate_oauth_scope_format!(oauth_scope)
+ unless Scope.values.include?(restricted_to) # rubocop:disable Performance/InefficientHashSearch
+ raise ArgumentError, "unknown Warden scope: #{restricted_to}"
+ end
+
+ Doorkeeper.configuration.scopes.add(oauth_scope)
+ internal_oauth_scope_registry << oauth_scope
+ restricted_oauth_scope_registry[restricted_to] << oauth_scope
+ end
+
+ def restricted_oauth_scopes(warden_scope)
+ restricted_oauth_scope_registry.fetch(warden_scope, []).to_a
+ end
+
+ def internal_oauth_scopes
+ internal_oauth_scope_registry.to_a
+ end
+
+ private
+
+ def validate_oauth_scope_format!(oauth_scope)
+ prefix, kind, resource, action, *rest = oauth_scope.split(":", -1)
+ return if prefix == "op" &&
+ OAUTH_SCOPE_KINDS.include?(kind) &&
+ OAUTH_SCOPE_RESOURCES.include?(resource) &&
+ OAUTH_SCOPE_ACTION_FORMAT.match?(action.to_s) &&
+ rest.empty?
+
+ raise ArgumentError,
+ "invalid OAuth scope #{oauth_scope.inspect}: expected op::: " \
+ "with kind in #{OAUTH_SCOPE_KINDS.inspect}, resource in #{OAUTH_SCOPE_RESOURCES.inspect} " \
+ "and a lowercase action"
+ end
+
+ def restricted_oauth_scope_registry
+ @restricted_oauth_scope_registry ||= Hash.new { |hash, key| hash[key] = Set.new }
+ end
+
+ def internal_oauth_scope_registry
+ @internal_oauth_scope_registry ||= Set.new
+ end
end
##
@@ -249,9 +312,10 @@ def scope_realm(scope = nil)
Manager.scope_config(scope).realm || default_realm
end
- def response_header(env:, scope: nil, error: nil, error_description: nil)
+ def response_header(env:, scope: nil, oauth_scopes: nil, error: nil, error_description: nil)
+ oauth_scope = oauth_scopes ? oauth_scopes.join(" ") : scope
header = %{Bearer realm="#{scope_realm(scope)}", resource_metadata="#{resource_metadata(env)}"}
- header << %{, scope="#{escape_string scope}"} if scope
+ header << %{, scope="#{escape_string oauth_scope}"} if oauth_scope
if error
header << %{, error="#{escape_string error}"}
diff --git a/lib_static/open_project/authentication/strategies/warden/doorkeeper_oauth.rb b/lib_static/open_project/authentication/strategies/warden/doorkeeper_oauth.rb
index a6fd8fae1c11..31285aa27214 100644
--- a/lib_static/open_project/authentication/strategies/warden/doorkeeper_oauth.rb
+++ b/lib_static/open_project/authentication/strategies/warden/doorkeeper_oauth.rb
@@ -36,6 +36,8 @@ module Warden
class DoorkeeperOAuth < ::Warden::Strategies::Base
include FailWithHeader
+ ACCESS_TOKEN_ENV_KEY = "openproject.authentication.oauth_access_token"
+
# The strategy is supposed to handle bearer tokens that are not JWT.
# These tokens are issued by OpenProject
def valid?
@@ -56,22 +58,27 @@ def authenticate!
access_token = ::Doorkeeper::OAuth::Token.authenticate(decorated_request,
*Doorkeeper.configuration.access_token_methods)
return fail_with_header!(error: "invalid_token") if access_token_invalid?(access_token)
- return fail_with_header!(error: "insufficient_scope") if !access_token.includes_scope?(scope)
+ return fail_with_header!(error: "insufficient_scope") if !access_token.includes_scope?(*accepted_scopes)
user_id = access_token.resource_owner_id || access_token.application.client_credentials_user_id
- authenticate_user(user_id) if user_id
+ authenticate_user(user_id, access_token) if user_id
end
private
+ def accepted_scopes
+ [scope.to_s, *OpenProject::Authentication.restricted_oauth_scopes(scope)]
+ end
+
def access_token_invalid?(access_token)
access_token.blank? || access_token.expired? || access_token.revoked? || !access_token.application.enabled?
end
- def authenticate_user(id)
+ def authenticate_user(id, access_token)
# ServiceAccount is found explicitly because ServiceAccount is builtin, but User.active excludes builtin entities.
user = id && User.active.where(id:).or(ServiceAccount.where(id:, status: ServiceAccount.statuses[:active])).first
if user
+ env[ACCESS_TOKEN_ENV_KEY] = access_token
success!(user)
else
fail_with_header!(error: "invalid_token")
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..8ff6cfd16105 100644
--- a/modules/documents/app/services/documents/oauth/ensure_application_service.rb
+++ b/modules/documents/app/services/documents/oauth/ensure_application_service.rb
@@ -34,7 +34,7 @@ module OAuth
#
# This service is responsible for finding or creating a Doorkeeper OAuth application
# that is used for authenticating the YJS provider with the OpenProject API.
- # The application is created as a confidential client with API v3 scope.
+ # The application is created as a confidential client with the edit documents scope.
class EnsureApplicationService < BaseServices::BaseCallable
APPLICATION_NAME = "Documents OAuth Application"
APPLICATION_UID = "documents_yjs_provider"
@@ -42,7 +42,7 @@ class EnsureApplicationService < BaseServices::BaseCallable
def perform
application = find_or_create_application
- if application.persisted?
+ if application.persisted? && application.errors.empty?
ServiceResult.success(result: application)
else
ServiceResult.failure(errors: application.errors)
@@ -53,11 +53,20 @@ def perform
def find_or_create_application
existing = Doorkeeper::Application.find_by(uid: APPLICATION_UID)
- return existing if existing
+ return ensure_edit_documents_scope(existing) if existing
create_application
end
+ def ensure_edit_documents_scope(application)
+ return application if application.scopes.to_a == [OpenProject::Documents::EDIT_DOCUMENTS_OAUTH_SCOPE]
+
+ ::OAuth::Applications::UpdateService
+ .new(model: application, user: User.system)
+ .call(scopes: OpenProject::Documents::EDIT_DOCUMENTS_OAUTH_SCOPE)
+ .result
+ end
+
def create_application
result = ::OAuth::Applications::CreateService
.new(user: User.system)
@@ -65,7 +74,7 @@ def create_application
uid: APPLICATION_UID,
name: APPLICATION_NAME,
redirect_uri: "urn:ietf:wg:oauth:2.0:oob",
- scopes: "api_v3",
+ scopes: OpenProject::Documents::EDIT_DOCUMENTS_OAUTH_SCOPE,
confidential: true,
owner: User.system
)
diff --git a/modules/documents/app/services/documents/oauth/generate_token_service.rb b/modules/documents/app/services/documents/oauth/generate_token_service.rb
index 62187038e0c8..c1580b325617 100644
--- a/modules/documents/app/services/documents/oauth/generate_token_service.rb
+++ b/modules/documents/app/services/documents/oauth/generate_token_service.rb
@@ -56,7 +56,7 @@ def perform
def create_access_token(application)
application.access_tokens.create(
resource_owner_id: @user.id,
- scopes: "api_v3",
+ scopes: OpenProject::Documents::EDIT_DOCUMENTS_OAUTH_SCOPE,
expires_in: 5.minutes.to_i
)
end
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..e5c6e3ee024a 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,66 @@
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
+
+ def initialize(user:, document:, previous_token: nil)
super()
@user = user
@document = document
- @project = project
+ @previous_token = previous_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 perform # rubocop:disable Metrics/AbcSize
+ 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 !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 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 +105,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 +119,73 @@ 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 access_token&.includes_scope?(OpenProject::Documents::EDIT_DOCUMENTS_OAUTH_SCOPE)
+ 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 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..e63fa6c9ef33 100644
--- a/modules/documents/config/locales/en.yml
+++ b/modules/documents/config/locales/en.yml
@@ -96,6 +96,11 @@ en:
success: Real-time collaboration has been enabled.
button_add_type: "Type"
+ collaboration_token:
+ errors:
+ collaboration_disabled: "Real-time collaboration is not enabled."
+ 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?"
@@ -182,6 +187,10 @@ en:
label_document_type: "Type"
label_documents: "Documents"
+ oauth:
+ scopes:
+ "op:resource:documents:edit": "Read and edit documents"
+
permission_manage_documents: "Manage documents"
permission_view_documents: "View documents"
plugin_openproject_documents:
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..3cda7cd4970f
--- /dev/null
+++ b/modules/documents/lib/api/v3/documents/collaboration_token_api.rb
@@ -0,0 +1,74 @@
+# 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
+ after_validation do
+ authorize_logged_in
+ 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)
+ .call
+
+ if call.failure?
+ if 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..75eb50f4ab92 100644
--- a/modules/documents/lib/api/v3/documents/documents_api.rb
+++ b/modules/documents/lib/api/v3/documents/documents_api.rb
@@ -56,16 +56,18 @@ 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
+ route_setting :oauth_scopes, [::OpenProject::Documents::EDIT_DOCUMENTS_OAUTH_SCOPE]
get do
::API::V3::Documents::DocumentRepresenter.new(document,
current_user:,
embed_links: true)
end
+ route_setting :oauth_scopes, [::OpenProject::Documents::EDIT_DOCUMENTS_OAUTH_SCOPE]
patch do
doc = document
request_body = JSON.parse(request.body.read)
@@ -83,6 +85,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..a983b4a55e8f 100644
--- a/modules/documents/lib/open_project/documents/engine.rb
+++ b/modules/documents/lib/open_project/documents/engine.rb
@@ -28,6 +28,8 @@
#++
module OpenProject::Documents
+ EDIT_DOCUMENTS_OAUTH_SCOPE = "op:resource:documents:edit"
+
class Engine < ::Rails::Engine
engine_name :openproject_documents
@@ -111,6 +113,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
@@ -123,6 +129,11 @@ class Engine < ::Rails::Engine
mount ::API::V3::Documents::DocumentsAPI
end
+ config.to_prepare do
+ OpenProject::Authentication.register_restricted_oauth_scope(OpenProject::Documents::EDIT_DOCUMENTS_OAUTH_SCOPE,
+ restricted_to: OpenProject::Authentication::Scope::API_V3)
+ end
+
# Add documents to allowed search params
additional_permitted_attributes search: %i(documents)
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..9aee8d4b0e79
--- /dev/null
+++ b/modules/documents/spec/requests/api/v3/documents/collaboration_token_resource_spec.rb
@@ -0,0 +1,317 @@
+# 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)
+ expect(access_token.scopes.to_a).to eq([OpenProject::Documents::EDIT_DOCUMENTS_OAUTH_SCOPE])
+ 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 lacks the edit documents scope" 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/requests/api/v3/documents/edit_documents_scope_spec.rb b/modules/documents/spec/requests/api/v3/documents/edit_documents_scope_spec.rb
new file mode 100644
index 000000000000..4b3c214a7104
--- /dev/null
+++ b/modules/documents/spec/requests/api/v3/documents/edit_documents_scope_spec.rb
@@ -0,0 +1,175 @@
+# 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 access with the edit documents scope" do
+ include Rack::Test::Methods
+ include API::V3::Utilities::PathHelper
+
+ include_context "with a collaboration token endpoint setup"
+
+ let(:permissions) { %i(view_documents manage_documents) }
+ let(:access_token) { collaboration_access_token_for(current_user) }
+
+ before do
+ header "Authorization", "Bearer #{access_token.plaintext_token}"
+ end
+
+ def request_with_json(method, path, body = nil)
+ header "Content-Type", "application/json"
+ public_send(method, path, body&.to_json)
+ end
+
+ describe "the endpoints opting into the scope" do
+ it "allows reading the document" do
+ get api_v3_paths.document(document.id)
+
+ expect(last_response).to have_http_status(:ok)
+ expect(last_response.body).to be_json_eql(document.id.to_json).at_path("id")
+ end
+
+ it "allows updating the document" do
+ request_with_json(:patch, api_v3_paths.document(document.id), { title: "Updated by collaboration" })
+
+ expect(last_response).to have_http_status(:ok)
+ expect(document.reload.title).to eq("Updated by collaboration")
+ end
+ end
+
+ describe "all other endpoints" do
+ let(:expected_message) do
+ I18n.t("api_v3.errors.insufficient_scope",
+ granted: "'#{OpenProject::Documents::EDIT_DOCUMENTS_OAUTH_SCOPE}'",
+ required: "api_v3")
+ end
+ let(:expected_www_authenticate) do
+ 'Bearer realm="OpenProject API", ' \
+ 'resource_metadata="http://test.host/.well-known/oauth-protected-resource", ' \
+ 'scope="api_v3", error="insufficient_scope"'
+ end
+
+ let(:request_method) { :get }
+ let(:body) { nil }
+
+ shared_examples "rejects the edit documents scope" do
+ it "responds with 403 InsufficientScope" do
+ request_with_json(request_method, path, body)
+
+ expect(last_response).to have_http_status(:forbidden)
+ expect(last_response.headers["WWW-Authenticate"]).to eq(expected_www_authenticate)
+ expect(last_response.body)
+ .to be_json_eql("urn:openproject-org:api:v3:errors:InsufficientScope".to_json)
+ .at_path("errorIdentifier")
+ expect(last_response.body).to be_json_eql(expected_message.to_json).at_path("message")
+ end
+ end
+
+ context "for GET /api/v3/users/me" do
+ let(:path) { api_v3_paths.user("me") }
+
+ it_behaves_like "rejects the edit documents scope"
+ end
+
+ context "for GET /api/v3/work_packages" do
+ let(:path) { api_v3_paths.work_packages }
+
+ it_behaves_like "rejects the edit documents scope"
+ end
+
+ context "for GET /api/v3/documents" do
+ let(:path) { api_v3_paths.documents }
+
+ it_behaves_like "rejects the edit documents scope"
+ end
+
+ context "for GET /api/v3/documents/:id/attachments" do
+ let(:path) { api_v3_paths.attachments_by_document(document.id) }
+
+ it_behaves_like "rejects the edit documents scope"
+ end
+
+ context "for POST /api/v3/documents/:id/collaboration_token" do
+ let(:request_method) { :post }
+ let(:path) { api_v3_paths.document_collaboration_token(document.id) }
+
+ it_behaves_like "rejects the edit documents scope"
+ end
+
+ context "for POST /api/v3/documents/:id/collaboration_token with a previous token" do
+ let(:request_method) { :post }
+ let(:path) { api_v3_paths.document_collaboration_token(document.id) }
+ let(:body) { { token: "irrelevant" } }
+
+ it_behaves_like "rejects the edit documents scope"
+ end
+ end
+
+ context "with an access token granting another restricted scope" do
+ let(:access_token) { create(:oauth_access_token, resource_owner: current_user, scopes: "other_scope") }
+
+ before do
+ allow(OpenProject::Authentication).to receive(:restricted_oauth_scopes).and_call_original
+ allow(OpenProject::Authentication)
+ .to receive(:restricted_oauth_scopes)
+ .with(:api_v3)
+ .and_return([OpenProject::Documents::EDIT_DOCUMENTS_OAUTH_SCOPE, "other_scope"])
+ end
+
+ it "responds with 403 InsufficientScope naming all scopes accepted by the endpoint" do
+ get api_v3_paths.document(document.id)
+
+ expect(last_response).to have_http_status(:forbidden)
+ expect(last_response.headers["WWW-Authenticate"]).to include(%{scope="api_v3 #{OpenProject::Documents::EDIT_DOCUMENTS_OAUTH_SCOPE}"})
+ expect(last_response.body)
+ .to be_json_eql(I18n.t("api_v3.errors.insufficient_scope_any",
+ granted: "'other_scope'",
+ accepted: "'api_v3', '#{OpenProject::Documents::EDIT_DOCUMENTS_OAUTH_SCOPE}'").to_json)
+ .at_path("message")
+ end
+ end
+
+ context "with an api_v3 access token" do
+ let(:access_token) { create(:oauth_access_token, resource_owner: current_user) }
+
+ it "allows reading the user, the documents and their attachments" do
+ [api_v3_paths.user("me"),
+ api_v3_paths.documents,
+ api_v3_paths.document(document.id),
+ api_v3_paths.attachments_by_document(document.id)].each do |path|
+ get path
+
+ expect(last_response).to have_http_status(:ok), "expected #{path} to respond with 200"
+ 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..92789d3ad18e 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
@@ -48,6 +48,7 @@
application = result.result
expect(application.uid).to eq(described_class::APPLICATION_UID)
+ expect(application.scopes.to_a).to eq([OpenProject::Documents::EDIT_DOCUMENTS_OAUTH_SCOPE])
end
end
@@ -65,6 +66,13 @@
expect(result).to be_success
expect(result.result).to eq(existing_application)
end
+
+ it "restricts the application to the edit documents scope" do
+ expect { service_call }
+ .to change { existing_application.reload.scopes.to_a }
+ .from(["api_v3"])
+ .to([OpenProject::Documents::EDIT_DOCUMENTS_OAUTH_SCOPE])
+ end
end
end
end
diff --git a/modules/documents/spec/services/documents/oauth/generate_token_service_spec.rb b/modules/documents/spec/services/documents/oauth/generate_token_service_spec.rb
index f9e8bc70cee6..64aa240e5a79 100644
--- a/modules/documents/spec/services/documents/oauth/generate_token_service_spec.rb
+++ b/modules/documents/spec/services/documents/oauth/generate_token_service_spec.rb
@@ -50,6 +50,12 @@
expect(token.resource_owner_id).to eq(user.id)
end
+
+ it "creates a token that only grants the edit documents scope" do
+ token = service_call.result
+
+ expect(token.scopes.to_a).to eq([OpenProject::Documents::EDIT_DOCUMENTS_OAUTH_SCOPE])
+ end
end
context "with different users" do
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..0903b50d79e3 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,9 @@
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:).call }
let(:project) { create(:project) }
let(:document) { create(:document, project:) }
@@ -93,6 +94,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 +148,259 @@ 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 "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 lacks the edit documents scope" 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..24096af0b9e4
--- /dev/null
+++ b/modules/documents/spec/support/shared/collaboration_token_endpoint.rb
@@ -0,0 +1,232 @@
+# 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 insufficient scope" do
+ post_collaboration_token_request
+
+ expect_api_error(403, "InsufficientScope",
+ I18n.t("api_v3.errors.insufficient_scope",
+ granted: "'#{OpenProject::Documents::EDIT_DOCUMENTS_OAUTH_SCOPE}'",
+ required: "api_v3"))
+ expect(last_response.headers["WWW-Authenticate"]).to include('error="insufficient_scope"')
+ 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 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)
+ 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
diff --git a/spec/contracts/oauth/applications/base_contract_spec.rb b/spec/contracts/oauth/applications/base_contract_spec.rb
index 1a6668754e47..e0c4d3794c01 100644
--- a/spec/contracts/oauth/applications/base_contract_spec.rb
+++ b/spec/contracts/oauth/applications/base_contract_spec.rb
@@ -92,4 +92,43 @@
it_behaves_like "oauth application contract is invalid"
end
end
+
+ describe ":scopes" do
+ let(:application) { build(:oauth_application, scopes: "api_v3 mcp") }
+
+ before do
+ allow(OpenProject::Authentication).to receive(:internal_oauth_scopes).and_return(["mcp"])
+ end
+
+ context "if an internal scope is assigned by an admin" do
+ it_behaves_like "oauth application contract is invalid"
+
+ it "adds an error on the scopes" do
+ contract = described_class.new(application, user)
+ contract.validate
+
+ expect(contract.errors.symbols_for(:scopes)).to contain_exactly(:inclusion)
+ end
+ end
+
+ context "if an internal scope is assigned by the system" do
+ let(:user) { User.system }
+
+ it_behaves_like "oauth application contract is valid"
+ end
+
+ context "if an application with an internal scope is changed otherwise by an admin" do
+ let(:application) do
+ create(:oauth_application, scopes: "api_v3 mcp").tap { |app| app.name = "Renamed" }
+ end
+
+ it_behaves_like "oauth application contract is valid"
+ end
+
+ context "if only non-internal scopes are assigned" do
+ let(:application) { build_stubbed(:oauth_application, secret: "my_secret", scopes: "api_v3") }
+
+ it_behaves_like "oauth application contract is valid"
+ end
+ end
end
diff --git a/spec/controllers/oauth/applications_controller_spec.rb b/spec/controllers/oauth/applications_controller_spec.rb
index b56cbca26530..8c74a1dc6a82 100644
--- a/spec/controllers/oauth/applications_controller_spec.rb
+++ b/spec/controllers/oauth/applications_controller_spec.rb
@@ -68,6 +68,21 @@
expect(response.status).to be 200
expect(response).to render_template :new
end
+
+ context "with an internal scope registered" do
+ render_views
+
+ before do
+ allow(OpenProject::Authentication).to receive(:internal_oauth_scopes).and_return(["mcp"])
+ end
+
+ it "does not offer the internal scope" do
+ get :new
+
+ expect(response.body).to have_field("application[scopes][]", with: "api_v3")
+ expect(response.body).to have_no_field("application[scopes][]", with: "mcp")
+ end
+ end
end
describe "#edit" do
diff --git a/spec/lib/open_project/authentication/register_restricted_oauth_scope_spec.rb b/spec/lib/open_project/authentication/register_restricted_oauth_scope_spec.rb
new file mode 100644
index 000000000000..36ee7fc06423
--- /dev/null
+++ b/spec/lib/open_project/authentication/register_restricted_oauth_scope_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 OpenProject::Authentication, ".register_restricted_oauth_scope" do # rubocop:disable RSpec/SpecFilePathFormat
+ let(:doorkeeper_scopes) { Doorkeeper::OAuth::Scopes.new }
+ let(:oauth_scope) { "op:resource:documents:read" }
+ let(:restricted_to) { OpenProject::Authentication::Scope::API_V3 }
+
+ subject(:register) { described_class.register_restricted_oauth_scope(oauth_scope, restricted_to:) }
+
+ before do
+ allow(Doorkeeper.configuration).to receive(:scopes).and_return(doorkeeper_scopes)
+ allow(described_class).to receive_messages(internal_oauth_scope_registry: Set.new,
+ restricted_oauth_scope_registry: Hash.new { |hash, key| hash[key] = Set.new })
+ end
+
+ it "registers the scope with Doorkeeper as internal and restricted to the Warden scope" do
+ register
+
+ expect(doorkeeper_scopes.all).to eq([oauth_scope])
+ expect(described_class.internal_oauth_scopes).to eq([oauth_scope])
+ expect(described_class.restricted_oauth_scopes(restricted_to)).to eq([oauth_scope])
+ end
+
+ it "is idempotent" do
+ 2.times { described_class.register_restricted_oauth_scope(oauth_scope, restricted_to:) }
+
+ expect(doorkeeper_scopes.all).to eq([oauth_scope])
+ expect(described_class.internal_oauth_scopes).to eq([oauth_scope])
+ expect(described_class.restricted_oauth_scopes(restricted_to)).to eq([oauth_scope])
+ end
+
+ shared_examples "rejects the scope" do
+ it "raises an ArgumentError and registers nothing" do
+ expect { register }.to raise_error(ArgumentError)
+
+ expect(doorkeeper_scopes.all).to be_empty
+ expect(described_class.internal_oauth_scopes).to be_empty
+ expect(described_class.restricted_oauth_scopes(restricted_to)).to be_empty
+ end
+ end
+
+ {
+ "missing op prefix" => "resource:documents:edit",
+ "wrong prefix" => "xx:resource:documents:edit",
+ "unknown kind" => "op:feature:documents:edit",
+ "unknown resource" => "op:resource:wikis:edit",
+ "too few segments" => "op:resource:documents",
+ "too many segments" => "op:resource:documents:edit:all",
+ "empty action" => "op:resource:documents:",
+ "uppercase action" => "op:resource:documents:Edit",
+ "invalid action" => "op:resource:documents:edit-all"
+ }.each do |description, invalid_scope|
+ context "with #{description} (#{invalid_scope})" do
+ let(:oauth_scope) { invalid_scope }
+
+ it_behaves_like "rejects the scope"
+ end
+ end
+
+ it "names the scope and the expected format in the error message" do
+ expect { described_class.register_restricted_oauth_scope("op:resource:wikis:edit", restricted_to:) }
+ .to raise_error(ArgumentError, /"op:resource:wikis:edit".*op:::.*"resource".*"documents"/)
+ end
+
+ context "with an unknown Warden scope" do
+ let(:restricted_to) { :unknown }
+
+ it_behaves_like "rejects the scope"
+
+ it "names the unknown Warden scope in the error message" do
+ expect { register }.to raise_error(ArgumentError, "unknown Warden scope: unknown")
+ end
+ end
+end
diff --git a/spec/lib/open_project/authentication/strategies/warden/doorkeeper_oauth_spec.rb b/spec/lib/open_project/authentication/strategies/warden/doorkeeper_oauth_spec.rb
new file mode 100644
index 000000000000..c4dd3ef1cdf1
--- /dev/null
+++ b/spec/lib/open_project/authentication/strategies/warden/doorkeeper_oauth_spec.rb
@@ -0,0 +1,94 @@
+# 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 OpenProject::Authentication::Strategies::Warden::DoorkeeperOAuth do
+ subject(:strategy) { described_class.new(env, warden_scope) }
+
+ let(:user) { create(:user) }
+ let(:warden_scope) { :api_v3 }
+ let(:token_scopes) { "api_v3" }
+ let(:access_token) { create(:oauth_access_token, resource_owner: user, scopes: token_scopes) }
+ let(:env) do
+ Rack::MockRequest.env_for("/api/v3/projects", "HTTP_AUTHORIZATION" => "Bearer #{access_token.plaintext_token}")
+ end
+
+ before do
+ allow(OpenProject::Authentication).to receive(:restricted_oauth_scopes).and_call_original
+ allow(OpenProject::Authentication)
+ .to receive(:restricted_oauth_scopes)
+ .with(:api_v3)
+ .and_return(["restricted_scope"])
+ end
+
+ shared_examples "authenticates the user" do
+ it "succeeds and exposes the access token in the env" do
+ strategy.authenticate!
+
+ expect(strategy.result).to eq(:success)
+ expect(strategy.user).to eq(user)
+ expect(env[described_class::ACCESS_TOKEN_ENV_KEY]).to eq(access_token)
+ end
+ end
+
+ shared_examples "fails with insufficient scope" do
+ it "fails without exposing the access token" do
+ strategy.authenticate!
+
+ expect(strategy.result).to eq(:failure)
+ expect(strategy.message).to eq("insufficient_scope")
+ expect(env).not_to have_key(described_class::ACCESS_TOKEN_ENV_KEY)
+ end
+ end
+
+ context "with a token granting the Warden scope" do
+ it_behaves_like "authenticates the user"
+ end
+
+ context "with a token granting a restricted scope registered for the Warden scope" do
+ let(:token_scopes) { "restricted_scope" }
+
+ it_behaves_like "authenticates the user"
+ end
+
+ context "with a token granting a restricted scope registered for another Warden scope" do
+ let(:warden_scope) { :mcp }
+ let(:token_scopes) { "restricted_scope" }
+
+ it_behaves_like "fails with insufficient scope"
+ end
+
+ context "with a token granting an unknown scope" do
+ let(:token_scopes) { "unknown_scope" }
+
+ it_behaves_like "fails with insufficient scope"
+ end
+end
diff --git a/spec/lib/open_project/authentication/www_authenticate_spec.rb b/spec/lib/open_project/authentication/www_authenticate_spec.rb
new file mode 100644
index 000000000000..43c710281d8b
--- /dev/null
+++ b/spec/lib/open_project/authentication/www_authenticate_spec.rb
@@ -0,0 +1,55 @@
+# 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 OpenProject::Authentication::WWWAuthenticate do
+ describe ".response_header" do
+ let(:env) { Rack::MockRequest.env_for("http://test.host/api/v3/projects") }
+ let(:resource_metadata) { 'resource_metadata="http://test.host/.well-known/oauth-protected-resource"' }
+
+ it "uses the Warden scope as the scope attribute" do
+ expect(described_class.response_header(env:, scope: :api_v3, error: "insufficient_scope"))
+ .to eq(%{Bearer realm="OpenProject API", #{resource_metadata}, scope="api_v3", error="insufficient_scope"})
+ end
+
+ it "lists the given OAuth scopes space-separated as the scope attribute" do
+ oauth_scope = "op:resource:documents:edit"
+ header = described_class.response_header(env:,
+ scope: :api_v3,
+ oauth_scopes: ["api_v3", oauth_scope],
+ error: "insufficient_scope")
+
+ expect(header).to eq(
+ %{Bearer realm="OpenProject API", #{resource_metadata}, scope="api_v3 #{oauth_scope}", error="insufficient_scope"}
+ )
+ end
+ end
+end
diff --git a/spec/requests/oauth/oauth_authorization_server_metadata_spec.rb b/spec/requests/oauth/oauth_authorization_server_metadata_spec.rb
index 569b73e51ae5..8dd8f922d2bf 100644
--- a/spec/requests/oauth/oauth_authorization_server_metadata_spec.rb
+++ b/spec/requests/oauth/oauth_authorization_server_metadata_spec.rb
@@ -56,4 +56,14 @@
get "/.well-known/oauth-authorization-server"
expect(last_response.body).to match_json_schema(expected_schema)
end
+
+ it "does not advertise internal scopes" do
+ allow(OpenProject::Authentication).to receive(:internal_oauth_scopes).and_return(["mcp"])
+
+ get "/.well-known/oauth-authorization-server"
+
+ scopes_supported = JSON.parse(last_response.body)["scopes_supported"]
+ expect(scopes_supported).to include("api_v3")
+ expect(scopes_supported).not_to include("mcp")
+ end
end