diff --git a/app/views/users/refresh_token.js.erb b/app/views/users/refresh_token.js.erb
index 1c7f52e44a..76f409f940 100644
--- a/app/views/users/refresh_token.js.erb
+++ b/app/views/users/refresh_token.js.erb
@@ -1,6 +1,8 @@
-var msg = '<%= @success ? _("Successfully regenerate your API token.") : _("Unable to regenerate your API token.") %>';
+// This view is called by both InternalUserAccessTokensController#create (provides @v2_token)
+// and UsersController#refresh_token (does not provide @v2_token).
+var msg = '<%= @success ? _("Successfully regenerated your API token.") : _("Unable to regenerate your API token.") %>';
-var context = $('#api-token');
-context.html('<%= escape_javascript(render partial: "/devise/registrations/api_token", locals: { user: current_user }) %>');
+var context = $('#api-tokens');
+context.html('<%= escape_javascript(render partial: "/devise/registrations/api_token", locals: { user: current_user, v2_token: @v2_token }) %>');
renderNotice(msg);
toggleSpinner(false);
diff --git a/config/initializers/_dmproadmap.rb b/config/initializers/_dmproadmap.rb
index 23b5fcf13c..50fb61bfed 100644
--- a/config/initializers/_dmproadmap.rb
+++ b/config/initializers/_dmproadmap.rb
@@ -64,6 +64,8 @@ class Application < Rails::Application
# Used throughout the system via ApplicationService.application_name
config.x.application.name = 'DMP Assistant'
+ # 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 0751a5f429..0991ecb08d 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -216,6 +216,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..60b279eb71
--- /dev/null
+++ b/lib/tasks/doorkeeper.rake
@@ -0,0 +1,15 @@
+# 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
+ 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..dc71c16e79
--- /dev/null
+++ b/spec/requests/api/v2/internal_user_access_tokens_controller_spec.rb
@@ -0,0 +1,94 @@
+# 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) }
+
+ describe 'POST #create' do
+ def post_create_token
+ post api_v2_internal_user_access_token_path
+ end
+
+ context 'when user is not authenticated' do
+ before do
+ # Enable CSRF protection for this test
+ allow_any_instance_of(ActionController::Base).to receive(:protect_against_forgery?).and_return(true)
+ end
+
+ # NOTE: In production, CSRF protection will reject unauthenticated
+ # POST requests before Pundit authorization is reached.
+ # NOTE: When `current_user == nil`, `authorize current_user, :internal_user_v2_access_token?`
+ # raises a Pundit error. However, the CSRF exception is raised first.
+ it 'rejects the request with 422 due to CSRF' do
+ expect do
+ post_create_token
+ end.to raise_error(ActionController::InvalidAuthenticityToken)
+ 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 plaintext token' do
+ post_create_token
+
+ expect(assigns(:v2_token)).to be_a(String)
+ expect(assigns(:v2_token)).not_to be_blank
+ 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..7455771740
--- /dev/null
+++ b/spec/services/api/v2/internal_user_access_token_service_spec.rb
@@ -0,0 +1,72 @@
+# 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) }
+
+ def create_internal_user_access_token
+ create(:oauth_access_token, application: oauth_app, resource_owner_id: user.id, scopes: 'read')
+ end
+
+ describe '#rotate!' do
+ def rotate_token_expectations(plaintext_token, old_token = nil) # rubocop:disable Metrics/AbcSize
+ # Doorkeeper hashes token via Digest::SHA256
+ hashed = Digest::SHA256.hexdigest(plaintext_token)
+ new_token = Doorkeeper::AccessToken.find_by!(token: hashed)
+ expect(new_token).to be_present
+ 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')
+ expect(old_token.revoked_at).not_to be_nil if old_token
+ end
+
+ shared_examples 'token rotation' do |has_old_token|
+ it "#{if has_old_token
+ 'revokes the old token and creates a new one'
+ else
+ 'creates a new token'
+ end}
+ (returns plaintext)" do
+ plaintext_token = nil
+ # Ensure .rotate!(user) creates a new AccessToken db entry for user
+ expect { plaintext_token = described_class.rotate!(user) }
+ .to change { Doorkeeper::AccessToken.where(resource_owner_id: user.id).count }
+ .by(1)
+ if has_old_token
+ old_token.reload
+ rotate_token_expectations(plaintext_token, old_token)
+ else
+ rotate_token_expectations(plaintext_token)
+ end
+ end
+ end
+
+ context 'when a token already exists' do
+ let!(:old_token) { create_internal_user_access_token }
+ include_examples 'token rotation', true
+ end
+
+ context 'when no token exists' do
+ include_examples 'token rotation', false
+ 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..89ef408502
--- /dev/null
+++ b/spec/views/devise/registrations/_v2_api_token.html.erb_spec.rb
@@ -0,0 +1,55 @@
+# 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 }
+
+ def render_token_partial(token: nil)
+ render partial: 'devise/registrations/v2_api_token', locals: { user: user, token: token }
+ end
+
+ context 'when the OAuth application exists' do
+ let!(:oauth_app) { create(:oauth_application, name: app_name) }
+
+ it 'displays the regenerate button when no token is present' do
+ render_token_partial(token: nil)
+ expect(rendered).to have_selector('button', text: 'Regenerate token')
+ end
+
+ context 'when user has a token' do
+ let(:plaintext_token) { 'plaintext-token-value' }
+
+ it 'displays the token and disables the regenerate button' do
+ render_token_partial(token: plaintext_token)
+ expect(rendered).to have_selector('code', text: plaintext_token)
+ expect(rendered).not_to have_content('Click the button below to generate an API token')
+ expect(rendered).to have_selector('button[disabled]', text: 'Regenerate token')
+ end
+ end
+
+ context 'when user does not have a token' do
+ it 'displays the generate message' do
+ render_token_partial(token: nil)
+ 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_token_partial(token: nil)
+ 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_token_partial(token: nil)
+ expect(rendered).not_to have_selector('button', text: 'Regenerate token')
+ expect(rendered).not_to have_selector('code')
+ end
+ end
+end