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 200b7ddac5..cfc7a21687 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'
@@ -77,7 +79,8 @@ class Application < Rails::Application
# The link to the API documentation - used in emails about the API
config.x.application.api_documentation_urls = {
v0: 'https://github.com/DMPRoadmap/roadmap/wiki/API-V0-Documentation',
- v1: 'https://github.com/DMPRoadmap/roadmap/wiki/API-V1-Documentation'
+ v1: 'https://github.com/DMPRoadmap/roadmap/wiki/API-v1-Documentation',
+ v2: 'https://github.com/DMPRoadmap/roadmap/wiki/API-v2-Documentation'
}
# The links that appear on the home page. Add any number of links
config.x.application.welcome_links = [
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..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..3a59becfb3
--- /dev/null
+++ b/spec/requests/api/v2/internal_user_access_tokens_controller_spec.rb
@@ -0,0 +1,91 @@
+# 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
+ # 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 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..e543ba83f7
--- /dev/null
+++ b/spec/views/devise/registrations/_api_token.html.erb_spec.rb
@@ -0,0 +1,30 @@
+# 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) }
+
+ 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..712431bd60
--- /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('#api-token-val')
+ 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('#api-token-val')
+ 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