[75226] Update XWiki auth integration (#23321)

Update the XWiki auth screen: added client secret, aligned the UI with the storage UI. 
Auth now supports only registered clients on the XWiki side. 

---------

Co-authored-by: Jan Sandbrink <j.sandbrink@openproject.com>
This commit is contained in:
Yauheni Suhakou
2026-05-29 12:04:10 +02:00
committed by GitHub
parent f4ddfe11c8
commit 33198e8d68
25 changed files with 133 additions and 161 deletions
@@ -36,11 +36,9 @@ module OAuthClients
validates :client_id, presence: true, length: { maximum: 255 }
attribute :client_secret, writable: true
validates :client_secret, presence: true, if: :client_secret_required?
validates :client_secret, presence: true
validates :client_secret, length: { maximum: 255 }
def client_secret_required? = true
attribute :integration_type, writable: true
validates :integration_type, presence: true
@@ -2,12 +2,12 @@
component_wrapper do
flex_layout(mb: 3) do |general_info_row|
general_info_row.with_row do
render(Primer::Beta::Text.new(font_weight: :bold)) { t("wikis.admin.wiki_providers.xwiki_instance") }
render(Primer::Beta::Text.new(font_weight: :bold)) { t("wikis.provider_types.#{wiki_provider}.name") }
end
general_info_row.with_row do
render(Primer::Beta::Text.new(color: :subtle, test_selector: "wiki-provider-configuration-instructions")) do
t(".xwiki_instance_description")
t(".provider_description")
end
end
@@ -1,12 +1,14 @@
<%=
component_wrapper do
flex_layout(flex_items: :center) do |credentials_row|
credentials_row.with_row do
render(Primer::Beta::Text.new(font_weight: :bold)) { t("wikis.admin.wiki_providers.oauth.openproject_oauth") }
end
credentials_row.with_row(mb: 3) do
render(Primer::Beta::Text.new(color: :subtle)) { t("wikis.admin.wiki_providers.#{wiki_provider}.oauth.openproject_oauth_description") }
render(Primer::Alpha::Banner.new(icon: :alert, scheme: :warning)) do
helpers.link_translate(
"wikis.instructions.xwiki.oauth_application_details_html",
links: { xwiki_admin_link: xwiki_admin_url },
external: true
)
end
end
credentials_row.with_row(mb: 3) do
@@ -30,6 +30,8 @@
module Wikis::Admin::Forms
class OAuthApplicationFormComponent < Wikis::Admin::WikiProviderComponent
OPENPROJECT_PLUGIN_ADMIN_PATH = "/bin/admin/XWiki/XWikiPreferences?editor=globaladmin&section=OpenProject"
def self.wrapper_key = :wiki_provider_oauth_application_section
delegate :oauth_application, to: :wiki_provider
@@ -43,5 +45,9 @@ module Wikis::Admin::Forms
url_helpers.edit_admin_settings_wiki_provider_path(wiki_provider)
end
end
def xwiki_admin_url
"#{wiki_provider.url.to_s.chomp('/')}#{OPENPROJECT_PLUGIN_ADMIN_PATH}"
end
end
end
@@ -14,19 +14,43 @@
render(Primer::Beta::Text.new(color: :subtle)) { t("wikis.admin.wiki_providers.#{wiki_provider}.oauth.provider_oauth_description") }
end
oauth_client_row.with_row(mb: 3) do
render(Primer::Alpha::TextField.new(
name: "oauth_client[client_id]",
label: t(".client_id"),
visually_hide_label: false,
value: resolved_oauth_client.client_id,
input_width: :large,
required: true,
validation_message: validation_message_for(:client_id)
))
end
oauth_client_row.with_row(mb: 3) do
render(Primer::Alpha::TextField.new(
name: "oauth_client[client_secret]",
label: t(".client_secret"),
visually_hide_label: false,
value: resolved_oauth_client.client_secret,
input_width: :large,
required: true,
validation_message: validation_message_for(:client_secret)
))
end
oauth_client_row.with_row(mb: 3) do
render(Primer::OpenProject::InputGroup.new(input_width: :large)) do |input_group|
input_group.with_text_input(
name: "oauth_client[client_id]",
label: t(".client_id"),
name: "oauth_client[redirect_uri]",
label: t(".redirect_uri"),
visually_hide_label: false,
value: resolved_oauth_client.client_id,
readonly: true
value: resolved_oauth_client.redirect_uri
)
input_group.with_trailing_action_clipboard_copy_button(
value: resolved_oauth_client.client_id,
value: resolved_oauth_client.redirect_uri,
aria: { label: t("button_copy_to_clipboard") }
)
input_group.with_caption { t(".redirect_uri_caption") }
end
end
@@ -51,7 +51,14 @@ module Wikis::Admin::Forms
def resolved_oauth_client
oauth_client ||
wiki_provider.oauth_client ||
wiki_provider.build_oauth_client(client_id: Wikis::XWikiProvider.generate_client_id)
wiki_provider.build_oauth_client(
client_id: Wikis::XWikiProvider.generate_client_id,
client_secret: Wikis::XWikiProvider.generate_client_secret
)
end
def validation_message_for(attribute)
resolved_oauth_client.errors.messages_for(attribute).to_sentence.presence
end
end
end
@@ -2,13 +2,16 @@
component_wrapper do
grid_layout("op-storage-view--row", tag: :div, align_items: :center) do |grid|
grid.with_area(:item, tag: :div, mr: 3) do
concat(render(Primer::Beta::Text.new(font_weight: :bold, mr: 1)) { t("wikis.admin.wiki_providers.xwiki_instance") })
concat(render(Primer::Beta::Text.new(font_weight: :bold, mr: 1)) { t("wikis.provider_types.#{wiki_provider}.name") })
concat(render(Primer::Beta::Label.new(scheme: :success, test_selector: "wiki-provider-general-info-status")) { t(:label_completed) })
end
grid.with_area(:description, tag: :div, color: :subtle, test_selector: "wiki-provider-description") do
concat(render(Primer::Beta::Text.new) { "#{wiki_provider.name} - " })
concat(render(Primer::Beta::Link.new(href: wiki_provider.url, target: "_blank", underline: true)) { wiki_provider.url })
concat(render(Primer::Beta::Text.new) { "#{t("wikis.provider_types.#{wiki_provider}.name")} - #{wiki_provider.name} - #{wiki_provider.url}" })
if wiki_provider.url.present?
concat(render(Primer::Beta::Text.new) { " - " })
concat(helpers.static_link_to(href: wiki_provider.url, label: t("wikis.buttons.open_wiki"), underline: true))
end
end
grid.with_area(:"icon-button", tag: :div, color: :muted) do
@@ -11,7 +11,11 @@
end
grid.with_area(:description, tag: :div, color: :subtle) do
render(Primer::Beta::Text.new) { t("wikis.admin.wiki_providers.#{wiki_provider}.oauth.openproject_oauth_description") }
if oauth_application.present?
render(Primer::Beta::Text.new) { "#{t(".label_oauth_client_id")}: #{oauth_application.uid}" }
else
render(Primer::Beta::Text.new) { t("wikis.admin.wiki_providers.#{wiki_provider}.oauth.openproject_oauth_description") }
end
end
if wiki_provider.persisted?
@@ -10,8 +10,12 @@
end
end
grid.with_area(:description, tag: :div, color: :subtle) do
render(Primer::Beta::Text.new) { t("wikis.admin.wiki_providers.#{wiki_provider}.oauth.provider_oauth_description") }
grid.with_area(:description, classes: "wb-break-word", tag: :div, color: :subtle) do
if oauth_client.present?
render(Primer::Beta::Text.new) { "#{t('.label_oauth_client_id')}: #{oauth_client.client_id}" }
else
render(Primer::Beta::Text.new) { t("wikis.admin.wiki_providers.#{wiki_provider}.oauth.provider_oauth_description") }
end
end
if wiki_provider.persisted?
@@ -29,7 +29,7 @@
end
grid.with_area(:provider, tag: :div, color: :subtle, mr: 3, hide: :sm) do
render(Primer::Beta::Text.new(color: :subtle)) { wiki_provider.model_name.human }
render(Primer::Beta::Text.new(color: :subtle)) { t("wikis.provider_types.#{wiki_provider}.name") }
end
grid.with_area(:time, tag: :div, color: :subtle, hide: :sm) do
@@ -1,38 +0,0 @@
# 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 Wikis
module OAuthClients
# XWiki uses a public OAuth client (RFC 6749 §2.1) — no client_secret is issued.
class XWikiCreateContract < ::OAuthClients::CreateContract
def client_secret_required? = false
end
end
end
@@ -42,7 +42,10 @@ module Wikis
menu_item :wiki_providers
def new
oauth_client = OAuthClient.new(client_id: Wikis::XWikiProvider.generate_client_id)
oauth_client = OAuthClient.new(
client_id: Wikis::XWikiProvider.generate_client_id,
client_secret: Wikis::XWikiProvider.generate_client_secret
)
update_via_turbo_stream(
component: Wikis::Admin::Forms::OAuthClientFormComponent.new(@wiki_provider,
@@ -92,7 +95,7 @@ module Wikis
end
def oauth_client_params
params.expect(oauth_client: [:client_id])
params.expect(oauth_client: %i[client_id client_secret])
end
def find_wiki_provider
@@ -45,7 +45,8 @@ module Wikis
class << self
def registry_prefix = "xwiki"
def generate_client_id = SecureRandom.uuid
def generate_client_id = "openproject-#{SecureRandom.hex(8)}"
def generate_client_secret = SecureRandom.hex(32)
end
def configured?
@@ -35,8 +35,6 @@ module Wikis
# OAuth2 configuration for XWiki's OIDC Provider extension.
#
# Deviations from a standard OAuth2 confidential client:
# - Public client: no client_secret; token_endpoint_auth_method is :none.
# - No pre-registration: XWiki accepts any client_id/redirect_uri; consent is stored on first auth.
# - No refresh tokens: tokens are long-lived; re-auth via ensure_connection if revoked.
# - No expires_in: tokens do not expire; expires_in is stored as nil.
#
@@ -69,9 +67,9 @@ module Wikis
def basic_rack_oauth_client
uri = provider_uri
# XWiki is a public client — no secret is used.
Rack::OAuth2::Client.new(
identifier: @oauth_client.client_id,
secret: @oauth_client.client_secret,
redirect_uri: @oauth_client.redirect_uri,
scheme: uri.scheme,
host: uri.host,
@@ -35,7 +35,7 @@ module Wikis
module Queries
class User < BaseQuery
def call(auth_strategy:)
url = "#{provider.url.chomp('/')}/rest/"
url = "#{provider.url.chomp('/')}/rest/wikis/xwiki/user"
Adapters::Authentication[auth_strategy].call do |http|
handle_response(http.get(url))
end
@@ -32,7 +32,7 @@ module Wikis
module OAuthClients
class CreateService < ::OAuthClients::CreateService
def initialize(**)
super(contract_class: Wikis::OAuthClients::XWikiCreateContract, **)
super(contract_class: ::OAuthClients::CreateContract, **)
end
def attributes_service_class
@@ -32,7 +32,12 @@ See COPYRIGHT and LICENSE files for more details.
<% html_title t(:label_administration), t(:project_module_wiki_platforms), page_title %>
<%= render(Primer::OpenProject::PageHeader.new) do |header| %>
<% header.with_title { page_title } %>
<% header.with_title do %>
<%= page_title %>
<%= render(Primer::Beta::Text.new(tag: :span, font_weight: :light, color: :muted)) do %>
(<%= t("wikis.provider_types.#{@wiki_provider}.name") %>)
<% end %>
<% end %>
<%
header.with_breadcrumbs(
@@ -90,7 +95,7 @@ See COPYRIGHT and LICENSE files for more details.
render(Wikis::Admin::WikiProviderViewComponent.new(@wiki_provider, wizard: @wizard))
end
page.with_sidebar(col_placement: :end, row_placement: :start) do
page.with_sidebar(col_placement: :end, row_placement: :end) do
render(Wikis::Admin::SidePanelComponent.new(@wiki_provider))
end
end
@@ -50,7 +50,7 @@ See COPYRIGHT and LICENSE files for more details.
test_selector: "wiki-providers-create-new-button" }
) do |menu|
menu.with_item(
label: I18n.t("activerecord.models.wikis/xwiki_provider"),
label: I18n.t("wikis.provider_types.xwiki.name"),
href: new_admin_settings_wiki_provider_path
)
end
@@ -27,17 +27,17 @@ See COPYRIGHT and LICENSE files for more details.
++#%>
<% html_title t(:label_administration), t(:project_module_wiki_platforms), t("wikis.admin.wiki_providers.label_new_xwiki_instance") %>
<% html_title t(:label_administration), t(:project_module_wiki_platforms), t("wikis.admin.wiki_providers.label_new_provider") %>
<%= render(Primer::OpenProject::PageHeader.new) do |header| %>
<% header.with_title(test_selector: "wiki-provider-new-page-header--title") do %>
<%= t("wikis.admin.wiki_providers.label_new_xwiki_instance") %>
<%= t("wikis.admin.wiki_providers.label_new_provider") %>
<% end %>
<% header.with_breadcrumbs([
{ href: admin_index_path, text: t(:label_administration) },
{ href: admin_settings_wiki_providers_path, text: t(:project_module_wiki_platforms) },
t("wikis.admin.wiki_providers.label_new_xwiki_instance")
t("wikis.admin.wiki_providers.label_new_provider")
]) %>
<% header.with_description(test_selector: "wiki-provider-new-page-header--description") do %>
+21 -9
View File
@@ -35,8 +35,16 @@ en:
buttons:
connect_account: Connect %{provider} account
done_continue: Done, continue
open_wiki: Open wiki
save_and_continue: Save and continue
wiki_page: Wiki page
instructions:
xwiki:
integration: XWiki Administration
oauth_application_details_html: The client secret value will not be accessible again after you close this window. Please copy these values into the [XWiki OpenProject Integration settings](xwiki_admin_link).
provider_types:
xwiki:
name: XWiki
delete_relation_page_link_confirmation_dialog:
title: Delete related wiki page link
heading: Delete related wiki page link?
@@ -80,12 +88,15 @@ en:
inline wiki page link will no longer be accessible. This action is irreversible.
forms:
general_info_form_component:
xwiki_instance_description: Please make sure you have administration privileges in your XWiki instance before doing the setup.
provider_description: Please make sure you have administration privileges in your XWiki instance before doing the setup.
oauth_application_form_component:
application_id: Application ID
application_secret: Application secret
application_id: OpenProject OAuth Application ID
application_secret: OpenProject OAuth Application secret
oauth_client_form_component:
client_id: Client ID
client_id: Wiki OAuth Client ID
client_secret: Wiki OAuth Client secret
redirect_uri: Redirect URI
redirect_uri_caption: Copy this value into the Redirect URI field of your XWiki OIDC client registration.
health_status:
show:
actions:
@@ -98,10 +109,12 @@ en:
title: Health Report
oauth_application_info_component:
confirm_replace_oauth_application: This action will reset the current OAuth credentials. After confirming you will have to reenter the credentials in your XWiki instance and all users will have to reauthorize. Are you sure you want to proceed?
label_oauth_client_id: OAuth Client ID
label_pending: Pending
replace_oauth_application: Replace OpenProject OAuth application
oauth_client_info_component:
confirm_replace_oauth_client: This action will reset the current XWiki OAuth credentials. All users will need to reauthorize against XWiki. Are you sure you want to proceed?
label_oauth_client_id: OAuth Client ID
label_pending: Pending
replace_oauth_client: Replace XWiki OAuth application
side_panel:
@@ -120,7 +133,7 @@ en:
index_description: Add an external wiki service to link work packages to existing wiki pages or create new ones directly from OpenProject.
label_add_new: Add new wiki provider
label_edit: Edit XWiki provider
label_new_xwiki_instance: New XWiki provider
label_new_provider: New XWiki provider
label_wiki_platform: Wiki provider
name_caption: Give your storage a name so that users can differentiate between multiple wiki platforms.
name_placeholder: XWiki knowledge base
@@ -128,15 +141,14 @@ en:
oauth:
openproject_oauth: OpenProject OAuth
sections:
general_information: Basic details
general_information: General information
oauth_configuration: OAuth configuration
url_caption: Please add the host address of your wiki platform including the https://. It should not be longer than 255 characters.
xwiki_instance: XWiki Instance
xwiki:
oauth:
openproject_oauth_description: Allow XWiki to access OpenProject data using an OAuth application. Copy the credentials below into your XWiki instance.
provider_oauth: XWiki OAuth
provider_oauth_description: Allow OpenProject to access XWiki data using OAuth. A client ID is automatically generated to identify OpenProject to XWiki — no manual configuration is needed on the XWiki side.
provider_oauth: Wiki OAuth
provider_oauth_description: Allow OpenProject to access XWiki data using OAuth. A client ID and client secret have been pre-generated for you — copy them along with the redirect URI into your XWiki OIDC client registration. You can change the client ID and secret if needed.
openproject_oauth_description: Allow XWiki to access OpenProject data using an OAuth.
xwiki_oauth: XWiki OAuth
xwiki_oauth_description: Allow OpenProject to access XWiki data using an OAuth.
@@ -65,9 +65,11 @@ RSpec.describe Wikis::Admin::OAuthApplicationInfoComponent, type: :component do
expect(page).to have_text(I18n.t(:label_completed))
end
it "renders the description" do
it "renders the oauth application id" do
render_inline(described_class.new(wiki_provider))
expect(page).to have_text(I18n.t("wikis.admin.wiki_providers.xwiki.oauth.openproject_oauth_description"))
expect(page).to have_text(
"#{I18n.t('wikis.admin.oauth_application_info_component.label_oauth_client_id')}: #{oauth_application.uid}"
)
end
it "renders a sync button with a confirm dialog" do
@@ -63,9 +63,11 @@ RSpec.describe Wikis::Admin::OAuthClientInfoComponent, type: :component do
expect(page).to have_text(I18n.t(:label_completed))
end
it "renders the description" do
it "renders the oauth client id" do
render_inline(described_class.new(wiki_provider))
expect(page).to have_text(I18n.t("wikis.admin.wiki_providers.xwiki.oauth.provider_oauth_description"))
expect(page).to have_text(
"#{I18n.t('wikis.admin.oauth_client_info_component.label_oauth_client_id')}: #{oauth_client.client_id}"
)
end
it "renders a sync icon button with a confirm dialog" do
@@ -1,63 +0,0 @@
# 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_module_spec_helper
require "contracts/shared/model_contract_shared_context"
RSpec.describe Wikis::OAuthClients::XWikiCreateContract do
include_context "ModelContract shared context"
let(:current_user) { create(:admin) }
let(:client_id) { SecureRandom.uuid }
let(:client_secret) { nil }
let(:integration) { create(:xwiki_provider) }
let(:oauth_client) { build(:oauth_client, client_id:, client_secret:, integration:) }
let(:contract) { described_class.new(oauth_client, current_user) }
describe "client_secret" do
context "when absent (nil)" do
include_examples "contract is valid"
end
context "when empty string" do
let(:client_secret) { "" }
include_examples "contract is valid"
end
context "when too long" do
let(:client_secret) { "X" * 257 }
include_examples "contract is invalid", client_secret: :too_long
end
end
end
@@ -32,7 +32,9 @@ require "spec_helper"
RSpec.describe Wikis::Adapters::Providers::XWiki::OAuthConfiguration do
let(:wiki_provider) { build_stubbed(:xwiki_provider, url: "https://xwiki.example.com/xwiki") }
let(:oauth_client) { build_stubbed(:oauth_client, client_id: "xwiki-uuid", integration: wiki_provider) }
let(:oauth_client) do
build_stubbed(:oauth_client, client_id: "xwiki-uuid", client_secret: "xwiki-secret", integration: wiki_provider)
end
before do
allow(wiki_provider).to receive(:oauth_client).and_return(oauth_client)
@@ -90,7 +92,7 @@ RSpec.describe Wikis::Adapters::Providers::XWiki::OAuthConfiguration do
it "configures the rack client with correct attributes" do
expect(rack_client).to have_attributes(
identifier: "xwiki-uuid",
secret: nil,
secret: "xwiki-secret",
token_endpoint: "/xwiki/oidc/token",
authorization_endpoint: "/xwiki/oidc/authorization"
)
@@ -35,7 +35,7 @@ RSpec.describe Wikis::Adapters::Providers::XWiki::Queries::User, :webmock do
let(:user) { build_stubbed(:user) }
let(:oauth_client) { build_stubbed(:oauth_client) }
let(:wiki_provider) { build_stubbed(:xwiki_provider, url: "https://xwiki.local/") }
let(:rest_url) { "https://xwiki.local/rest/" }
let(:user_url) { "https://xwiki.local/rest/wikis/xwiki/user" }
let(:auth_strategy) { Wikis::Adapters::Input::AuthStrategy.build(key: :bearer_token, user:, provider: wiki_provider).value! }
subject(:query) { described_class.new(model: wiki_provider) }
@@ -51,9 +51,9 @@ RSpec.describe Wikis::Adapters::Providers::XWiki::Queries::User, :webmock do
end
describe "#call" do
context "when the request succeeds with xwiki-user header" do
context "when the request succeeds with an xwiki-user header" do
before do
stub_request(:get, rest_url)
stub_request(:get, user_url)
.with(headers: { "Authorization" => "Bearer some-token" })
.to_return(status: 200, body: "", headers: { "xwiki-user" => "XWiki.admin" })
end
@@ -65,10 +65,10 @@ RSpec.describe Wikis::Adapters::Providers::XWiki::Queries::User, :webmock do
end
end
context "when the token is absent or invalid (XWiki returns 200 without xwiki-user header)" do
context "when the response is missing the xwiki-user header" do
before do
stub_request(:get, rest_url)
.to_return(status: 200, body: "", headers: {})
stub_request(:get, user_url)
.to_return(status: 200, body: "")
end
it "returns Failure with :unauthorized code" do
@@ -80,7 +80,7 @@ RSpec.describe Wikis::Adapters::Providers::XWiki::Queries::User, :webmock do
context "when XWiki returns a non-2xx status" do
before do
stub_request(:get, rest_url).to_return(status: 500, body: "Internal Server Error")
stub_request(:get, user_url).to_return(status: 401, body: "Unauthorized")
end
it "returns Failure with :request_failed code" do
@@ -91,7 +91,7 @@ RSpec.describe Wikis::Adapters::Providers::XWiki::Queries::User, :webmock do
end
context "when a network error occurs" do
before { stub_request(:get, rest_url).to_timeout }
before { stub_request(:get, user_url).to_timeout }
it "returns Failure with :connection_error code" do
result = query.call(auth_strategy:)