diff --git a/app/controllers/api/v2/internal_user_access_tokens_controller.rb b/app/controllers/api/v2/internal_user_access_tokens_controller.rb new file mode 100644 index 0000000000..6cb19454ec --- /dev/null +++ b/app/controllers/api/v2/internal_user_access_tokens_controller.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Api + module V2 + # Controller for managing the current user's internal V2 API access token. + # Provides token rotation for authenticated internal users. + # See Api::V2::InternalUserAccessTokenService for token implementation details. + class InternalUserAccessTokensController < ApplicationController + # POST "/api/v2/internal_user_access_token" + def create + authorize current_user, :internal_user_v2_access_token? + @v2_token = Api::V2::InternalUserAccessTokenService.rotate!(current_user) + @success = true + respond_to do |format| + format.js { render 'users/refresh_token' } + end + end + end + end +end diff --git a/app/javascript/application.js b/app/javascript/application.js index 4df7f76917..c12397a04c 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -23,6 +23,7 @@ import 'bootstrap-select'; // Utilities import './src/utils/accordion'; import './src/utils/autoComplete'; +import './src/utils/copyToken.js'; import './src/utils/externalLink'; import './src/utils/modalSearch'; import './src/utils/outOfFocus'; diff --git a/app/javascript/src/utils/copyToken.js b/app/javascript/src/utils/copyToken.js new file mode 100644 index 0000000000..3da9c90fb7 --- /dev/null +++ b/app/javascript/src/utils/copyToken.js @@ -0,0 +1,35 @@ +const initCopyToken = () => { + document.addEventListener('click', function (e) { + const button = e.target.closest('#copy-token-btn'); + if (!button) return; + + e.preventDefault(); + + // Prevent spam clicking + if (button.disabled) return; + + const tokenInput = document.getElementById('api-token-val'); + if (!tokenInput) return; + + const originalHTML = button.innerHTML; + + // Disable immediately + button.disabled = true; + + navigator.clipboard.writeText(tokenInput.value).then(() => { + // Replace button contents with check icon + button.innerHTML = ''; + + // Restore after 2s + setTimeout(() => { + button.innerHTML = originalHTML; + button.disabled = false; + }, 2000); + }).catch(() => { + button.disabled = false; + alert('Failed to copy token'); + }); + }); +}; + +initCopyToken(); diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb index 5a1e2c4dba..d2b9e49edb 100644 --- a/app/policies/user_policy.rb +++ b/app/policies/user_policy.rb @@ -55,6 +55,12 @@ def refresh_token? (@user.can_org_admin? && @user.can_use_api?) end + # Safe: only allows the signed-in user to generate/rotate their own token. + # These are first-party, user-scoped tokens and do not affect other users. + def internal_user_v2_access_token? + true + end + def merge? @user.can_super_admin? end diff --git a/app/services/api/v2/internal_user_access_token_service.rb b/app/services/api/v2/internal_user_access_token_service.rb new file mode 100644 index 0000000000..41f65397a8 --- /dev/null +++ b/app/services/api/v2/internal_user_access_token_service.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +module Api + module V2 + # Service responsible for user-scoped v2 API access tokens, strictly for + # internal users of this application. + # + # Tokens issued by this service are functionally equivalent to Personal Access + # Tokens (PATs) for first-party usage. They are minted directly for a user + # who is already authenticated in the application, bypassing the standard + # OAuth 2.0 authorization_code redirect and consent flow. + # + # This design is intentional: + # - tokens are internal to this application (first-party) + # - tokens are owned by a single user and scoped accordingly + # - token creation, rotation, and revocation happen entirely within the app UI + # + # Tokens are stored as Doorkeeper::AccessToken records to leverage existing + # scoping, expiry, and revocation mechanisms. + # + # This service does NOT support third-party OAuth clients or delegated consent flows. + class InternalUserAccessTokenService + READ_SCOPE = 'read' + INTERNAL_OAUTH_APP_NAME = Rails.application.config.x.application.internal_oauth_app_name + + class << self + def rotate!(user) + revoke_existing!(user) + + token = Doorkeeper::AccessToken.create!( + application_id: application!.id, + resource_owner_id: user.id, + scopes: READ_SCOPE, + expires_in: nil # Overrides Doorkeeper's `access_token_expires_in` + ) + token.plaintext_token + end + + # Used by views (e.g. devise/registrations/_v2_api_token.html.erb) to safely + # gate token UI if the internal OAuth application is missing. + def application_present? + application! + true + rescue StandardError => e + Rails.logger.error(e.message) + false + end + + private + + def application! + Doorkeeper::Application.find_by(name: INTERNAL_OAUTH_APP_NAME) || + raise( + StandardError, + "Required Doorkeeper application '#{INTERNAL_OAUTH_APP_NAME}' not found. " \ + 'Please ensure the application exists in the database.' + ) + end + + def revoke_existing!(user) + Doorkeeper::AccessToken.revoke_all_for(application!.id, user) + end + end + end + end +end diff --git a/app/views/devise/registrations/_api_token.html.erb b/app/views/devise/registrations/_api_token.html.erb index e308692b99..783c3bc9f5 100644 --- a/app/views/devise/registrations/_api_token.html.erb +++ b/app/views/devise/registrations/_api_token.html.erb @@ -1,25 +1,12 @@ <%# locals: user %> +<% v2_token ||= nil %> -<% api_wikis = Rails.configuration.x.application.api_documentation_urls %> -
-
- <%= label_tag(:api_token, _('Access token'), class: 'form-label') %> - <% if user.api_token.present? %> - <%= user.api_token %> - <% else %> - <%= _("Click the button below to generate an API token") %> - <% end %> -
-
- <%= label_tag(:api_information, _('Documentation'), class: 'form-label') %> -
- <%= _('See the documentation for v0 for more details on the original API which includes access to statistics, the full text of plans and the ability to connect users with departments.').html_safe % { api_v0_wiki: api_wikis[:v0] } %> -

- <%= _('See the documentation for v1 for more details on the API that supports the RDA Common metadata standard for DMPs.').html_safe % { api_v1_wiki: api_wikis[:v1], rda_standard_url: 'https://github.com/RDA-DMP-Common/RDA-DMP-Common-Standard' } %> -
-
- <%= link_to _("Regenerate token"), - refresh_token_user_path(user), - class: "btn btn-secondary", remote: true %> -
+
+ <%# v2 API token %> + <%= render partial: "devise/registrations/v2_api_token", locals: { user: user, token: v2_token } %> + + <% if user.can_use_api? %> + <%# v0/v1 API token %> + <%= render partial: "devise/registrations/legacy_api_token", locals: { user: user } %> + <% end %>
diff --git a/app/views/devise/registrations/_legacy_api_token.html.erb b/app/views/devise/registrations/_legacy_api_token.html.erb new file mode 100644 index 0000000000..8763eeb44c --- /dev/null +++ b/app/views/devise/registrations/_legacy_api_token.html.erb @@ -0,0 +1,36 @@ +<%# locals: user %> + +<% api_wikis = Rails.configuration.x.application.api_documentation_urls %> +
+
+ <%= _('Legacy API') %> +
+
+
+ <%= label_tag(:api_token, _('Access token'), class: 'form-label') %> + <% if user.api_token.present? %> + <%= user.api_token %> + <% else %> + <%= _("Click the button below to generate an API token") %> + <% end %> +
+
+ <%= label_tag(:api_information, _('Documentation'), class: 'form-label') %> +
+ <%= sanitize(_('See the documentation for v0 for more details on the original API which includes access to statistics, the full text of plans and the ability to connect users with departments.') % + { api_v0_wiki: api_wikis[:v0] }, + attributes: %w[href] + )%> +

+ <%= sanitize(_('See the documentation for v1 for more details on the API that supports the RDA Common metadata standard for DMPs.') % + { api_v1_wiki: api_wikis[:v1], rda_standard_url: 'https://github.com/RDA-DMP-Common/RDA-DMP-Common-Standard' }, + attributes: %w[href] + )%> +
+
+ <%= link_to _("Regenerate token"), + refresh_token_user_path(user), + class: "btn btn-secondary", remote: true %> +
+
+
diff --git a/app/views/devise/registrations/_v2_api_token.html.erb b/app/views/devise/registrations/_v2_api_token.html.erb new file mode 100644 index 0000000000..cda26ede21 --- /dev/null +++ b/app/views/devise/registrations/_v2_api_token.html.erb @@ -0,0 +1,62 @@ +<%# locals: user, token %> +<% api_wikis = Rails.configuration.x.application.api_documentation_urls %> + +
+
+ <%= _('V2 API') %> +
+
+ <% if Api::V2::InternalUserAccessTokenService.application_present? %> +
+ <%= label_tag(:api_token, _('Access token'), class: 'form-label') %> + <% if token.present? %> + <%= text_field_tag( + :api_token_val, + token, + id: 'api-token-val', + class: 'form-control', + style: 'width: auto;', + readonly: true + ) %> + + <%= button_tag( + _('Copy'), + id: 'copy-token-btn', + type: 'button', + class: 'btn btn-secondary' + ) %> +
+ <%= _( "Please copy this token now and store it somewhere safely." ) %>
+ <%= _( "It will disappear after you leave or refresh this page." ) %> +
+ <% else %> + <%= _( "Click the button below to generate an API token" ) %>
+
+ <%= _("If you previously generated and saved a token, please continue using that token.") %> +
+ <% end %> +
+ +
+ <%= button_tag _("Regenerate token"), + type: :submit, + class: 'btn btn-secondary', + disabled: token.present?, + data: { remote: true, url: api_v2_internal_user_access_token_path, method: :post } %> +
+
+ <%= label_tag(:api_information, _('Documentation'), class: 'form-label') %> +
+ <%= sanitize(_('See the documentation for v2 for more details on the API.') % + { api_v2_wiki: api_wikis[:v2] }, + attributes: %w[href] + )%> +
+ <% else %> +
+ <%= _("V2 API token service is currently unavailable. Please contact us for help.") %> + <%= mail_to Rails.application.config.x.organisation.helpdesk_email %> +
+ <% end %> +
+
diff --git a/app/views/devise/registrations/edit.html.erb b/app/views/devise/registrations/edit.html.erb index 487547d944..3896151aca 100644 --- a/app/views/devise/registrations/edit.html.erb +++ b/app/views/devise/registrations/edit.html.erb @@ -16,12 +16,10 @@ <%= _('Password') %> - <% if @user.can_use_api? %> - - <% end %> +
- <% if @user.can_use_api? %> -
-
-
- <%= render partial: 'devise/registrations/api_token', locals: { user: @user } %> -
+
+
+
+ <%= render partial: 'devise/registrations/api_token', locals: { user: @user } %>
- <% end %> +
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