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..a51e8ddf74 --- /dev/null +++ b/app/controllers/api/v2/internal_user_access_tokens_controller.rb @@ -0,0 +1,19 @@ +# 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? + @token = Api::V2::InternalUserAccessTokenService.rotate!(current_user) + respond_to do |format| + format.js { render 'users/refresh_token' } + end + end + end + end +end 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..874d8db44e --- /dev/null +++ b/app/services/api/v2/internal_user_access_token_service.rb @@ -0,0 +1,79 @@ +# 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 for_user(user) + Doorkeeper::AccessToken.find_by(user_token_filter(user)) + end + + def rotate!(user) + revoke_existing!(user) + + Doorkeeper::AccessToken.create!( + user_token_filter(user) + .merge(expires_in: nil) # Overrides Doorkeeper's `access_token_expires_in` + ) + 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! + @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 + .where(user_token_filter(user)) + .update_all(revoked_at: Time.current) + end + + def user_token_filter(user) + { + resource_owner_id: user.id, + application_id: application!.id, + scopes: READ_SCOPE, + revoked_at: nil + } + 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..411747108e 100644 --- a/app/views/devise/registrations/_api_token.html.erb +++ b/app/views/devise/registrations/_api_token.html.erb @@ -1,25 +1,11 @@ <%# locals: user %> -<% 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 } %> + + <% 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..0ef743f1de --- /dev/null +++ b/app/views/devise/registrations/_legacy_api_token.html.erb @@ -0,0 +1,30 @@ +<%# 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') %> +
+ <%= _('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 %> +
+
+
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..6c8ed92556 --- /dev/null +++ b/app/views/devise/registrations/_v2_api_token.html.erb @@ -0,0 +1,33 @@ +<%# locals: user %> + +
+
+ <%= _('V2 API') %> +
+
+ <% if Api::V2::InternalUserAccessTokenService.application_present? %> + <% token = Api::V2::InternalUserAccessTokenService.for_user(user) %> +
+ <%= label_tag(:api_token, _('Access token'), class: 'form-label') %> + <% if token.present? %> + <%= token.token %> + <% else %> + <%= _("Click the button below to generate an API token") %> + <% end %> +
+ +
+ <%= link_to _("Regenerate token"), + api_v2_internal_user_access_token_path, + method: :post, + class: 'btn btn-secondary', + remote: true %> +
+ <% 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..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