diff --git a/app/views/users/refresh_token.js.erb b/app/views/users/refresh_token.js.erb
index 1c7f52e44a..eae578970d 100644
--- a/app/views/users/refresh_token.js.erb
+++ b/app/views/users/refresh_token.js.erb
@@ -1,6 +1,6 @@
var msg = '<%= @success ? _("Successfully regenerate your API token.") : _("Unable to regenerate your API token.") %>';
-var context = $('#api-token');
+var context = $('#api-tokens');
context.html('<%= escape_javascript(render partial: "/devise/registrations/api_token", locals: { user: current_user }) %>');
renderNotice(msg);
toggleSpinner(false);
diff --git a/config/initializers/_dmproadmap.rb b/config/initializers/_dmproadmap.rb
index 200b7ddac5..92aca3d92d 100644
--- a/config/initializers/_dmproadmap.rb
+++ b/config/initializers/_dmproadmap.rb
@@ -67,6 +67,8 @@ class Application < Rails::Application
# Used throughout the system via ApplicationService.application_name
config.x.application.name = 'DMPRoadmap'
+ # Name of the internal Doorkeeper OAuth application for v2 API access tokens
+ config.x.application.internal_oauth_app_name = 'Internal v2 API Client'
# Used as the default domain when 'archiving' (aka anonymizing) a user account
# for example `jane.doe@uni.edu` becomes `1234@removed_accounts-example.org`
config.x.application.archived_accounts_email_suffix = '@removed_accounts-example.org'
diff --git a/config/routes.rb b/config/routes.rb
index 274c494d1d..eccd7e890a 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -211,6 +211,7 @@
resources :plans, only: %i[index show]
resources :templates, only: :index
+ resource :internal_user_access_token, only: :create, defaults: { format: :js }
end
end
diff --git a/lib/tasks/doorkeeper.rake b/lib/tasks/doorkeeper.rake
new file mode 100644
index 0000000000..8a436b5dac
--- /dev/null
+++ b/lib/tasks/doorkeeper.rake
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+namespace :doorkeeper do
+ desc 'Ensure internal OAuth application exists'
+ task ensure_internal_app: :environment do
+ app = Doorkeeper::Application.find_or_create_by!(
+ name: Rails.application.config.x.application.internal_oauth_app_name
+ ) do |a|
+ a.scopes = 'read'
+ a.confidential = true
+ # OOB redirect URI used only as a placeholder.
+ # Tokens are minted server-side for already-authenticated first-party users.
+ # No redirect, authorization code, or third-party client is involved,
+ # so there is no security risk despite OOB deprecation.
+ a.redirect_uri = 'urn:ietf:wg:oauth:2.0:oob'
+ end
+
+ puts "Internal OAuth app ready (id=#{app.id}, uid=#{app.uid})"
+ end
+end
diff --git a/spec/requests/api/v2/internal_user_access_tokens_controller_spec.rb b/spec/requests/api/v2/internal_user_access_tokens_controller_spec.rb
new file mode 100644
index 0000000000..84cd848546
--- /dev/null
+++ b/spec/requests/api/v2/internal_user_access_tokens_controller_spec.rb
@@ -0,0 +1,96 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe Api::V2::InternalUserAccessTokensController do
+ let(:user) { create(:user) }
+ let(:app_name) { Rails.application.config.x.application.internal_oauth_app_name }
+ let!(:oauth_app) { create(:oauth_application, name: app_name) }
+
+ before do
+ # Clear memoization between tests
+ Api::V2::InternalUserAccessTokenService.instance_variable_set(:@application, nil)
+ end
+
+ describe 'POST #create' do
+ def post_create_token
+ post api_v2_internal_user_access_token_path
+ end
+
+ context 'when user is not authenticated' do
+ # In production, CSRF protection would reject the request with a 422 error
+ # before it reaches Pundit. However, RSpec bypasses CSRF checks, so this
+ # test verifies that Pundit raises NotDefinedError when authorize is called
+ # with nil. This error won't occur in production due to CSRF protection.
+ it 'raises Pundit::NotDefinedError and does not create a token' do
+ expect do
+ expect do
+ post_create_token
+ end.to raise_error(Pundit::NotDefinedError)
+ end.not_to change { Doorkeeper::AccessToken.count }
+ end
+ end
+
+ context 'when user is authenticated' do
+ before { sign_in(user) }
+
+ it 'rotates the user token' do
+ post_create_token
+
+ expect(response).to have_http_status(:ok)
+ end
+
+ it 'creates a new token' do
+ expect do
+ post_create_token
+ end.to change { Doorkeeper::AccessToken.count }.by(1)
+ end
+
+ it 'assigns the token' do
+ post_create_token
+
+ expect(assigns(:token)).to be_a(Doorkeeper::AccessToken)
+ expect(assigns(:token).resource_owner_id).to eq(user.id)
+ end
+
+ it 'renders the refresh_token template' do
+ post_create_token
+
+ expect(response).to render_template('users/refresh_token')
+ end
+
+ context 'when a token already exists' do
+ let!(:old_token) do
+ create(:oauth_access_token, application: oauth_app, resource_owner_id: user.id, scopes: 'read')
+ end
+
+ it 'revokes the old token' do
+ post_create_token
+
+ old_token.reload
+ expect(old_token.revoked_at).not_to be_nil
+ end
+
+ it 'creates a new token' do
+ post_create_token
+
+ new_token = assigns(:token)
+ expect(new_token).not_to eq(old_token)
+ end
+ end
+ end
+
+ context 'when the internal OAuth application is missing' do
+ before do
+ sign_in(user)
+ oauth_app.destroy
+ end
+
+ it 'raises a StandardError' do
+ expect do
+ post_create_token
+ end.to raise_error(StandardError, /not found/)
+ end
+ end
+ end
+end
diff --git a/spec/services/api/v2/internal_user_access_token_service_spec.rb b/spec/services/api/v2/internal_user_access_token_service_spec.rb
new file mode 100644
index 0000000000..ef2c8a4c45
--- /dev/null
+++ b/spec/services/api/v2/internal_user_access_token_service_spec.rb
@@ -0,0 +1,87 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe Api::V2::InternalUserAccessTokenService do
+ let(:user) { create(:user) }
+ let(:app_name) { Rails.application.config.x.application.internal_oauth_app_name }
+ let!(:oauth_app) { create(:oauth_application, name: app_name) }
+
+ before do
+ # Clear memoization between tests
+ described_class.instance_variable_set(:@application, nil)
+ end
+
+ def create_internal_user_access_token
+ create(:oauth_access_token, application: oauth_app, resource_owner_id: user.id, scopes: 'read')
+ end
+
+ describe '#for_user' do
+ context 'when a token exists for the user' do
+ let!(:access_token) do
+ create_internal_user_access_token
+ end
+
+ it 'returns the access token' do
+ token = described_class.for_user(user)
+ expect(token).to be_present
+ expect(token.resource_owner_id).to eq(user.id)
+ end
+ end
+
+ context 'when no token exists for the user' do
+ it 'returns nil' do
+ token = described_class.for_user(user)
+ expect(token).to be_nil
+ end
+ end
+ end
+
+ describe '#rotate!' do
+ def rotate_token_expectations(new_token, old_token = nil) # rubocop:disable Metrics/AbcSize
+ expect(new_token).to be_persisted
+ expect(new_token.resource_owner_id).to eq(user.id)
+ expect(new_token.revoked_at).to be_nil
+ expect(new_token.scopes.to_s).to include('read')
+ return unless old_token
+
+ expect(new_token).not_to eq(old_token)
+ expect(old_token.revoked_at).not_to be_nil
+ end
+
+ context 'when a token already exists' do
+ let!(:old_token) do
+ create_internal_user_access_token
+ end
+
+ it 'revokes the old token and creates a new one' do
+ new_token = described_class.rotate!(user)
+ old_token.reload
+ rotate_token_expectations(new_token, old_token)
+ end
+ end
+
+ context 'when no token exists' do
+ it 'creates a new token' do
+ token = described_class.rotate!(user)
+ rotate_token_expectations(token)
+ end
+ end
+ end
+
+ describe '#application_present?' do
+ context 'when the app exists' do
+ it 'returns true' do
+ expect(described_class.application_present?).to be true
+ end
+ end
+
+ context 'when the app does not exist' do
+ before { oauth_app.destroy }
+
+ it 'returns false' do
+ expect(described_class.application_present?).to be false
+ end
+ end
+ end
+end
diff --git a/spec/views/devise/registrations/_api_token.html.erb_spec.rb b/spec/views/devise/registrations/_api_token.html.erb_spec.rb
new file mode 100644
index 0000000000..e4323ff9df
--- /dev/null
+++ b/spec/views/devise/registrations/_api_token.html.erb_spec.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe 'devise/registrations/_api_token.html.erb' do
+ let(:app_name) { Rails.application.config.x.application.internal_oauth_app_name }
+ let!(:oauth_app) { create(:oauth_application, name: app_name) }
+
+ before do
+ # Clear memoization between tests
+ Api::V2::InternalUserAccessTokenService.instance_variable_set(:@application, nil)
+ end
+
+ context 'When a user has the `use_api` permission' do
+ it 'renders both the v2 and legacy API token sections' do
+ user = create(:user, :org_admin)
+
+ render partial: 'devise/registrations/api_token', locals: { user: user }
+
+ expect(rendered).to have_selector('#v2-api-token')
+ expect(rendered).to have_selector('#legacy-api-token')
+ end
+ end
+
+ context 'When a user does not have the `use_api` permission' do
+ it 'renders only the v2 API token section' do
+ user = create(:user)
+
+ render partial: 'devise/registrations/api_token', locals: { user: user }
+
+ expect(rendered).to have_selector('#v2-api-token')
+ expect(rendered).not_to have_selector('#legacy-api-token')
+ end
+ end
+end
diff --git a/spec/views/devise/registrations/_v2_api_token.html.erb_spec.rb b/spec/views/devise/registrations/_v2_api_token.html.erb_spec.rb
new file mode 100644
index 0000000000..ae52746977
--- /dev/null
+++ b/spec/views/devise/registrations/_v2_api_token.html.erb_spec.rb
@@ -0,0 +1,66 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe 'devise/registrations/_v2_api_token.html.erb' do
+ let(:user) { create(:user) }
+ let(:app_name) { Rails.application.config.x.application.internal_oauth_app_name }
+
+ before do
+ # Clear memoization between tests
+ Api::V2::InternalUserAccessTokenService.instance_variable_set(:@application, nil)
+ end
+
+ context 'when the OAuth application exists' do
+ let!(:oauth_app) { create(:oauth_application, name: app_name) }
+
+ it 'displays the regenerate button' do
+ render partial: 'devise/registrations/v2_api_token', locals: { user: user }
+
+ expect(rendered).to have_link('Regenerate token',
+ href: api_v2_internal_user_access_token_path)
+ end
+
+ context 'when user has a token' do
+ let!(:token) do
+ create(:oauth_access_token,
+ application: oauth_app,
+ resource_owner_id: user.id,
+ scopes: 'read')
+ end
+
+ it 'displays the token' do
+ render partial: 'devise/registrations/v2_api_token', locals: { user: user }
+
+ expect(rendered).to have_selector('code', text: token.token)
+ expect(rendered).not_to have_content('Click the button below to generate an API token')
+ end
+ end
+
+ context 'when user does not have a token' do
+ it 'displays the generate message' do
+ render partial: 'devise/registrations/v2_api_token', locals: { user: user }
+
+ expect(rendered).to have_content('Click the button below to generate an API token')
+ expect(rendered).not_to have_selector('code')
+ end
+ end
+ end
+
+ context 'when the OAuth application does not exist' do
+ it 'displays the warning message and helpdesk email link' do
+ render partial: 'devise/registrations/v2_api_token', locals: { user: user }
+
+ expect(rendered).to have_selector('.alert-warning')
+ expect(rendered).to have_content('V2 API token service is currently unavailable')
+ expect(rendered).to have_link(href: "mailto:#{Rails.application.config.x.organisation.helpdesk_email}")
+ end
+
+ it 'does not display the token or regenerate button' do
+ render partial: 'devise/registrations/v2_api_token', locals: { user: user }
+
+ expect(rendered).not_to have_link('Regenerate token')
+ expect(rendered).not_to have_selector('code')
+ end
+ end
+end