Skip to content
Open
9 changes: 9 additions & 0 deletions app/contracts/oauth/applications/base_contract.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ def self.model
validate :validate_client_credential_user
validate :validate_integration
validate :validate_admin_only
validate :validate_internal_scopes

attribute :enabled
attribute :name
Expand All @@ -57,6 +58,14 @@ def validate_admin_only
errors.add :base, :error_unauthorized unless user.admin?
end

def validate_internal_scopes
return if user.is_a?(SystemUser) || !model.will_save_change_to_scopes?

if model.scopes.to_a.intersect?(OpenProject::Authentication.internal_oauth_scopes)
errors.add :scopes, :inclusion
end
end

def validate_integration
both = model.integration_id.present? && model.integration_type.present?
none = model.integration_id.nil? && model.integration_type.nil?
Expand Down
6 changes: 5 additions & 1 deletion app/controllers/oauth_metadata_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ def authorization_server
authorization_endpoint: oauth_authorization_url,
token_endpoint: oauth_token_url,
introspection_endpoint: oauth_introspect_url,
scopes_supported: Doorkeeper.configuration.scopes.to_a,
scopes_supported: supported_scopes,
response_types_supported: response_types(Doorkeeper.configuration.grant_flows),
grant_types_supported: grant_types,
service_documentation: OpenProject::Static::Links.url_for(:oauth_applications)
Expand All @@ -60,6 +60,10 @@ def protected_resource

private

def supported_scopes
Doorkeeper.configuration.scopes.to_a - OpenProject::Authentication.internal_oauth_scopes
end

def response_types(grant_types)
grant_types.filter_map do |grant|
case grant
Expand Down
5 changes: 3 additions & 2 deletions app/views/oauth/applications/_form.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,11 @@ See COPYRIGHT and LICENSE files for more details.
</span>
</div>

<% if Doorkeeper.configuration.scopes.all.length > 1 %>
<% selectable_scopes = Doorkeeper.configuration.scopes.to_a - OpenProject::Authentication.internal_oauth_scopes %>
<% if selectable_scopes.length > 1 %>
<div class="form--field">
<label class="form--label"><%= t("oauth.application.scopes") %></label>
<% Doorkeeper.configuration.scopes.each do |scope| %>
<% selectable_scopes.each do |scope| %>
<div class="form--field-container -vertical">
<label class="form--label-with-check-box">
<div class="form--check-box-container">
Expand Down
6 changes: 6 additions & 0 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
83 changes: 83 additions & 0 deletions docs/api/apiv3/components/schemas/collaboration_token_model.yml
Original file line number Diff line number Diff line change
@@ -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"
15 changes: 15 additions & 0 deletions docs/api/apiv3/components/schemas/document_model.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions docs/api/apiv3/openapi-spec.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down Expand Up @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions docs/api/apiv3/paths/document.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
147 changes: 147 additions & 0 deletions docs/api/apiv3/paths/document_collaboration_token.yml
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading