From e46519f5f36ba1631e8d0381d902dd9ec75649cb Mon Sep 17 00:00:00 2001 From: aaronskiba Date: Fri, 6 Feb 2026 09:43:01 -0700 Subject: [PATCH 01/19] Create internal Doorkeeper app via rake task - Creates a first-party Doorkeeper client for issuing internal v2 API tokens - Sets redirect_uri to OOB, scopes to 'read', and marks it as confidential - Ensures the internal application exists in all environments before token service is used --- config/initializers/_dmproadmap.rb | 2 ++ lib/tasks/doorkeeper.rake | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+) create mode 100644 lib/tasks/doorkeeper.rake 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/lib/tasks/doorkeeper.rake b/lib/tasks/doorkeeper.rake new file mode 100644 index 0000000000..8c25e4d4ef --- /dev/null +++ b/lib/tasks/doorkeeper.rake @@ -0,0 +1,19 @@ +# 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 + # redirect_uri value is only used as a placeholder here (required by Doorkeeper). + # Tokens are minted server-side for already-authenticated first-party users. + # No redirect, authorization code, or third-party client is involved. + a.redirect_uri = "#{Rails.application.routes.url_helpers.root_url}oauth/callback" + end + + puts "Internal OAuth app ready (id=#{app.id}, uid=#{app.uid})" + end +end From ccf0f928847631dd985f4f38286158ab2974d6b5 Mon Sep 17 00:00:00 2001 From: aaronskiba Date: Fri, 6 Feb 2026 09:50:14 -0700 Subject: [PATCH 02/19] Create `Api::V2::InternalUserAccessTokenService` This service manages user-scoped v2 API access tokens for internal app users. - Tokens are equivalent to first-party Personal Access Tokens (PATs) and are issued directly to authenticated users, bypassing the full OAuth 2.0 authorization_code flow. - Supports token creation, rotation, and revocation. - Uses Doorkeeper::AccessToken records for consistent scoping, expiry, and revocation handling. - Designed strictly for internal usage; third-party OAuth clients are not supported. --- .../v2/internal_user_access_token_service.rb | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 app/services/api/v2/internal_user_access_token_service.rb 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..afbe61704a --- /dev/null +++ b/app/services/api/v2/internal_user_access_token_service.rb @@ -0,0 +1,57 @@ +# 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' + APPLICATION = Doorkeeper::Application.find_by( + name: Rails.application.config.x.application.internal_oauth_app_name + ) + + class << self + def for_user(user) + Doorkeeper::AccessToken.find_by( + application_id: application!.id, + resource_owner_id: user.id, + scopes: READ_SCOPE, + revoked_at: nil + ) + end + + def rotate!(user) + revoke_existing!(user) + + 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` + ) + end + + private + + def revoke_existing!(user) + Doorkeeper::AccessToken.revoke_all_for(application!.id, user) + end + end + end + end +end From 797aeb619af043bb4190872b03c5cd176544ee26 Mon Sep 17 00:00:00 2001 From: aaronskiba Date: Fri, 6 Feb 2026 12:51:40 -0700 Subject: [PATCH 03/19] Add "POST /api/v2/internal_user_access_token" action & route Adds `Api::V2::InternalUserAccessTokensController#create` with Pundit authorization and routing. Also reuses the existing `users/refresh_token.js.erb` response to update the UI via JS. `@success` is read by `app/views/users/refresh_token.js.erb` (similar approach as `UsersController#refresh_token`) --- .../internal_user_access_tokens_controller.rb | 20 +++++++++++++++++++ app/policies/user_policy.rb | 6 ++++++ config/routes.rb | 1 + 3 files changed, 27 insertions(+) create mode 100644 app/controllers/api/v2/internal_user_access_tokens_controller.rb 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..1b3058abc8 --- /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? + @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/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/config/routes.rb b/config/routes.rb index 274c494d1d..cd75e490e0 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 end end From 965896b9c2f8ec197a395e2f6d3127795b84d781 Mon Sep 17 00:00:00 2001 From: aaronskiba Date: Fri, 6 Feb 2026 16:22:27 -0700 Subject: [PATCH 04/19] Add API v2 section to `/users/edit#api-details` This change updates `app/views/devise/registrations/_api_token.html.erb` to include support for the v2 API access token. Existing v0/v1 token support is retained. - Introduce V2 token lookup via `Api::V2::InternalUserAccessTokenService` - Display a dedicated V2 API access token section with its own regeneration action --- .../devise/registrations/_api_token.html.erb | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/app/views/devise/registrations/_api_token.html.erb b/app/views/devise/registrations/_api_token.html.erb index e308692b99..ae8faaabd5 100644 --- a/app/views/devise/registrations/_api_token.html.erb +++ b/app/views/devise/registrations/_api_token.html.erb @@ -1,7 +1,28 @@ <%# locals: user %> <% api_wikis = Rails.configuration.x.application.api_documentation_urls %> +<% v2_token = Api::V2::InternalUserAccessTokenService.for_user(user) %>
+ + <%# v2 API token %> +
+ <%= label_tag(:api_token, _('Access token'), class: 'form-label') %> + <% if v2_token.present? %> + <%= v2_token.token %> + <% else %> + <%= _("Click the button below to generate an API token") %> + <% end %> +
+ +
+ <%= link_to _("Regenerate token"), + api_v2_internal_user_access_token_path(format: :js), + method: :post, + class: 'btn btn-secondary', + remote: true %> +
+ + <%# v0/v1 API token %>
<%= label_tag(:api_token, _('Access token'), class: 'form-label') %> <% if user.api_token.present? %> From 4bbf690ac23100e3e01d5f8504a96530d9ff38ef Mon Sep 17 00:00:00 2001 From: aaronskiba Date: Fri, 6 Feb 2026 16:30:56 -0700 Subject: [PATCH 05/19] Refactor api_token into v2 & legacy partials This change breaks refactors `_api_token.html.erb` into additional separate partials: 1) app/views/devise/registrations/_legacy_api_token.html.erb 2) app/views/devise/registrations/_v2_api_token.html.erb In addition to the refactor, the following changes have been made: - `
` wrapper has been added in app/views/devise/registrations/_api_token.html.erb. - `app/views/users/refresh_token.js.erb` now references the '#api-tokens' wrapper. --- .../devise/registrations/_api_token.html.erb | 43 ++----------------- .../registrations/_legacy_api_token.html.erb | 25 +++++++++++ .../registrations/_v2_api_token.html.erb | 21 +++++++++ app/views/users/refresh_token.js.erb | 2 +- 4 files changed, 50 insertions(+), 41 deletions(-) create mode 100644 app/views/devise/registrations/_legacy_api_token.html.erb create mode 100644 app/views/devise/registrations/_v2_api_token.html.erb diff --git a/app/views/devise/registrations/_api_token.html.erb b/app/views/devise/registrations/_api_token.html.erb index ae8faaabd5..c0d7d3c1e5 100644 --- a/app/views/devise/registrations/_api_token.html.erb +++ b/app/views/devise/registrations/_api_token.html.erb @@ -1,46 +1,9 @@ <%# locals: user %> -<% api_wikis = Rails.configuration.x.application.api_documentation_urls %> -<% v2_token = Api::V2::InternalUserAccessTokenService.for_user(user) %> -
- +
<%# v2 API token %> -
- <%= label_tag(:api_token, _('Access token'), class: 'form-label') %> - <% if v2_token.present? %> - <%= v2_token.token %> - <% else %> - <%= _("Click the button below to generate an API token") %> - <% end %> -
- -
- <%= link_to _("Regenerate token"), - api_v2_internal_user_access_token_path(format: :js), - method: :post, - class: 'btn btn-secondary', - remote: true %> -
+ <%= render partial: "devise/registrations/v2_api_token", locals: { user: user } %> <%# v0/v1 API token %> -
- <%= 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 %> -
+ <%= render partial: "devise/registrations/legacy_api_token", locals: { user: user } %>
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..2fc12b78ce --- /dev/null +++ b/app/views/devise/registrations/_legacy_api_token.html.erb @@ -0,0 +1,25 @@ +<%# 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 %> +
+
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..2fadad0266 --- /dev/null +++ b/app/views/devise/registrations/_v2_api_token.html.erb @@ -0,0 +1,21 @@ +<%# locals: user %> + +<% 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(format: :js), + method: :post, + class: 'btn btn-secondary', + remote: true %> +
+
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); From 3782f0268fcdc597139c08e221e53696d88a4173 Mon Sep 17 00:00:00 2001 From: aaronskiba Date: Mon, 9 Feb 2026 13:49:59 -0700 Subject: [PATCH 06/19] Expose API Access tab to all users / restrict legacy token rendering The API Access tab is now visible to all users to support the new v2 API token, which is accessible to everyone. The existing v0/v1 legacy token remains restricted and continues to use the previous authorization and rendering logic within the tab. --- .../devise/registrations/_api_token.html.erb | 6 +++-- app/views/devise/registrations/edit.html.erb | 22 ++++++++----------- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/app/views/devise/registrations/_api_token.html.erb b/app/views/devise/registrations/_api_token.html.erb index c0d7d3c1e5..411747108e 100644 --- a/app/views/devise/registrations/_api_token.html.erb +++ b/app/views/devise/registrations/_api_token.html.erb @@ -4,6 +4,8 @@ <%# v2 API token %> <%= render partial: "devise/registrations/v2_api_token", locals: { user: user } %> - <%# v0/v1 API token %> - <%= render partial: "devise/registrations/legacy_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/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 %> +
From b4fc307070a59997ed9ac4828ac506141b1db364 Mon Sep 17 00:00:00 2001 From: aaronskiba Date: Mon, 9 Feb 2026 14:11:03 -0700 Subject: [PATCH 07/19] Improve styling for v2 + legacy API displays Styling changes can be viewed at /users/edit#api-details --- .../registrations/_legacy_api_token.html.erb | 43 +++++++++++-------- .../registrations/_v2_api_token.html.erb | 33 ++++++++------ 2 files changed, 43 insertions(+), 33 deletions(-) diff --git a/app/views/devise/registrations/_legacy_api_token.html.erb b/app/views/devise/registrations/_legacy_api_token.html.erb index 2fc12b78ce..0ef743f1de 100644 --- a/app/views/devise/registrations/_legacy_api_token.html.erb +++ b/app/views/devise/registrations/_legacy_api_token.html.erb @@ -1,25 +1,30 @@ <%# 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 %> +
+
+ <%= _('Legacy API') %>
-
- <%= 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 %> +
+
+ <%= 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 index 2fadad0266..753b4344a6 100644 --- a/app/views/devise/registrations/_v2_api_token.html.erb +++ b/app/views/devise/registrations/_v2_api_token.html.erb @@ -1,21 +1,26 @@ <%# locals: user %> <% 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 %> +
+
+ <%= _('V2 API') %>
+
+
+ <%= 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(format: :js), - method: :post, - class: 'btn btn-secondary', - remote: true %> +
+ <%= link_to _("Regenerate token"), + api_v2_internal_user_access_token_path(format: :js), + method: :post, + class: 'btn btn-secondary', + remote: true %> +
From 543619010a83248b812e6371886444581ac626d1 Mon Sep 17 00:00:00 2001 From: aaronskiba Date: Thu, 12 Feb 2026 13:49:12 -0700 Subject: [PATCH 08/19] Add handling for missing internal OAuth app `InternalUserAccessTokenService`: add `application!` (lookup + raise) and `application_present?` (safe check with logging) `_v2_api_token.html.erb`: gate token UI on `application_present?` and show a warning when missing. --- .../v2/internal_user_access_token_service.rb | 23 +++++++++-- .../registrations/_v2_api_token.html.erb | 39 +++++++++++-------- 2 files changed, 43 insertions(+), 19 deletions(-) diff --git a/app/services/api/v2/internal_user_access_token_service.rb b/app/services/api/v2/internal_user_access_token_service.rb index afbe61704a..62bcb266c3 100644 --- a/app/services/api/v2/internal_user_access_token_service.rb +++ b/app/services/api/v2/internal_user_access_token_service.rb @@ -21,9 +21,7 @@ module V2 # This service does NOT support third-party OAuth clients or delegated consent flows. class InternalUserAccessTokenService READ_SCOPE = 'read' - APPLICATION = Doorkeeper::Application.find_by( - name: Rails.application.config.x.application.internal_oauth_app_name - ) + INTERNAL_OAUTH_APP_NAME = Rails.application.config.x.application.internal_oauth_app_name class << self def for_user(user) @@ -46,8 +44,27 @@ def rotate!(user) ) 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 diff --git a/app/views/devise/registrations/_v2_api_token.html.erb b/app/views/devise/registrations/_v2_api_token.html.erb index 753b4344a6..d9bbb318d7 100644 --- a/app/views/devise/registrations/_v2_api_token.html.erb +++ b/app/views/devise/registrations/_v2_api_token.html.erb @@ -1,26 +1,33 @@ <%# locals: user %> -<% token = Api::V2::InternalUserAccessTokenService.for_user(user) %>
<%= _('V2 API') %>
-
- <%= 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 %> -
+ <% 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(format: :js), - method: :post, - class: 'btn btn-secondary', - remote: true %> -
+
+ <%= link_to _("Regenerate token"), + api_v2_internal_user_access_token_path(format: :js), + 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 %>
From 8ef06e2c10f8cd1e436d9dde1a5af1e39d7e4d2c Mon Sep 17 00:00:00 2001 From: aaronskiba Date: Fri, 13 Feb 2026 14:17:10 -0700 Subject: [PATCH 09/19] Add test coverage for internal v2 token generation Add request specs for InternalUserAccessTokensController - Include both authenticated & unauthenticated user scenarios - Include both present & absent internal OAuth app scenarios Add service specs for InternalUserAccessTokenService - Test token retrieval, rotation, and OAuth app presence - Verify old token revocation when rotating Add view specs for API token partials - Test legacy partial rendering based on `user.can_use_api?` - Test OAuth application availability scenarios --- ...rnal_user_access_tokens_controller_spec.rb | 91 +++++++++++++++++++ ...internal_user_access_token_service_spec.rb | 82 +++++++++++++++++ .../registrations/_api_token.html.erb_spec.rb | 35 +++++++ .../_v2_api_token.html.erb_spec.rb | 61 +++++++++++++ 4 files changed, 269 insertions(+) create mode 100644 spec/requests/api/v2/internal_user_access_tokens_controller_spec.rb create mode 100644 spec/services/api/v2/internal_user_access_token_service_spec.rb create mode 100644 spec/views/devise/registrations/_api_token.html.erb_spec.rb create mode 100644 spec/views/devise/registrations/_v2_api_token.html.erb_spec.rb 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..45de868bfb --- /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(format: :js) + 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..25e0622bfa --- /dev/null +++ b/spec/services/api/v2/internal_user_access_token_service_spec.rb @@ -0,0 +1,82 @@ +# 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 '#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..761c2852ba --- /dev/null +++ b/spec/views/devise/registrations/_v2_api_token.html.erb_spec.rb @@ -0,0 +1,61 @@ +# 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 } + + 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(format: :js)) + 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 From 6a54ad5b5ff9d3f52bfe82d5b4fce9efc8c765fe Mon Sep 17 00:00:00 2001 From: aaronskiba Date: Fri, 13 Feb 2026 14:21:43 -0700 Subject: [PATCH 10/19] Set default format for internal_user_access_token route Add `defaults: { format: :js }` to the internal_user_access_token route, allowing callers to omit the explicit format parameter. --- app/views/devise/registrations/_v2_api_token.html.erb | 2 +- config/routes.rb | 2 +- .../api/v2/internal_user_access_tokens_controller_spec.rb | 2 +- spec/views/devise/registrations/_v2_api_token.html.erb_spec.rb | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/views/devise/registrations/_v2_api_token.html.erb b/app/views/devise/registrations/_v2_api_token.html.erb index d9bbb318d7..6c8ed92556 100644 --- a/app/views/devise/registrations/_v2_api_token.html.erb +++ b/app/views/devise/registrations/_v2_api_token.html.erb @@ -18,7 +18,7 @@
<%= link_to _("Regenerate token"), - api_v2_internal_user_access_token_path(format: :js), + api_v2_internal_user_access_token_path, method: :post, class: 'btn btn-secondary', remote: true %> diff --git a/config/routes.rb b/config/routes.rb index cd75e490e0..eccd7e890a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -211,7 +211,7 @@ resources :plans, only: %i[index show] resources :templates, only: :index - resource :internal_user_access_token, only: :create + resource :internal_user_access_token, only: :create, defaults: { format: :js } 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 index 45de868bfb..5a624d5294 100644 --- a/spec/requests/api/v2/internal_user_access_tokens_controller_spec.rb +++ b/spec/requests/api/v2/internal_user_access_tokens_controller_spec.rb @@ -9,7 +9,7 @@ describe 'POST #create' do def post_create_token - post api_v2_internal_user_access_token_path(format: :js) + post api_v2_internal_user_access_token_path end context 'when user is not authenticated' do 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 index 761c2852ba..3e904a1969 100644 --- a/spec/views/devise/registrations/_v2_api_token.html.erb_spec.rb +++ b/spec/views/devise/registrations/_v2_api_token.html.erb_spec.rb @@ -13,7 +13,7 @@ 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(format: :js)) + href: api_v2_internal_user_access_token_path) end context 'when user has a token' do From 6fd8a06cc0196b17ecae919a41d404b3cc511945 Mon Sep 17 00:00:00 2001 From: aaronskiba Date: Tue, 17 Feb 2026 14:24:14 -0700 Subject: [PATCH 11/19] Fix string typo in `refresh_token.js.erb` --- app/views/users/refresh_token.js.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/users/refresh_token.js.erb b/app/views/users/refresh_token.js.erb index eae578970d..242b51d346 100644 --- a/app/views/users/refresh_token.js.erb +++ b/app/views/users/refresh_token.js.erb @@ -1,4 +1,4 @@ -var msg = '<%= @success ? _("Successfully regenerate your API token.") : _("Unable to regenerate your API token.") %>'; +var msg = '<%= @success ? _("Successfully regenerated your API token.") : _("Unable to regenerate your API token.") %>'; var context = $('#api-tokens'); context.html('<%= escape_javascript(render partial: "/devise/registrations/api_token", locals: { user: current_user }) %>'); From 9904d76ecb6cde19c08002414d6d2216ee747034 Mon Sep 17 00:00:00 2001 From: aaronskiba Date: Thu, 19 Feb 2026 12:34:33 -0700 Subject: [PATCH 12/19] Remove `redirect_uri` from internal OAuth app Commit 6e7c21c8abaca524d292f72150b9e2f05dca3e61 allows us to have a NULL `redirect_uri`. Previously, this value was merely a placeholder due to the NOT NULL constraint. --- lib/tasks/doorkeeper.rake | 4 ---- 1 file changed, 4 deletions(-) diff --git a/lib/tasks/doorkeeper.rake b/lib/tasks/doorkeeper.rake index 8c25e4d4ef..60b279eb71 100644 --- a/lib/tasks/doorkeeper.rake +++ b/lib/tasks/doorkeeper.rake @@ -8,10 +8,6 @@ namespace :doorkeeper do ) do |a| a.scopes = 'read' a.confidential = true - # redirect_uri value is only used as a placeholder here (required by Doorkeeper). - # Tokens are minted server-side for already-authenticated first-party users. - # No redirect, authorization code, or third-party client is involved. - a.redirect_uri = "#{Rails.application.routes.url_helpers.root_url}oauth/callback" end puts "Internal OAuth app ready (id=#{app.id}, uid=#{app.uid})" From 8cb922758cb7dc8cae7afc4c38535f980dd27d80 Mon Sep 17 00:00:00 2001 From: aaronskiba Date: Thu, 19 Feb 2026 17:02:26 -0700 Subject: [PATCH 13/19] Adapt internal user v2 token handling to hashed tokens With `hash_token_secrets` enabled, `token.plaintext_token` is only available at creation or rotation time and cannot be retrieved later. This change ensures secure handling of API tokens in line with best practices for hashed token storage. - Update `InternalUserAccessTokenService#rotate!` to return `token.plaintext_token` at creation time - Pass the plaintext token from InternalUserAccessTokensController#create to `app/views/devise/registrations/_v2_api_token.html.erb` - In `_v2_api_token.html.erb`, render the plaintext token when available and display a warning to users to copy and store the token securely, as it will not be shown again after leaving or refreshing the page. - Updated all affected Spec files as well. - The `context 'when user is not authenticated' do` test has been updated. It now enables CSRF protection, enabling the test to accurately capture the behaviour that will be captured in production. --- .../internal_user_access_tokens_controller.rb | 2 +- .../v2/internal_user_access_token_service.rb | 12 +--- .../devise/registrations/_api_token.html.erb | 3 +- .../registrations/_v2_api_token.html.erb | 14 ++-- app/views/users/refresh_token.js.erb | 4 +- ...rnal_user_access_tokens_controller_spec.rb | 6 +- ...internal_user_access_token_service_spec.rb | 68 ++++++++----------- .../registrations/_api_token.html.erb_spec.rb | 5 -- .../_v2_api_token.html.erb_spec.rb | 38 +++++------ 9 files changed, 66 insertions(+), 86 deletions(-) diff --git a/app/controllers/api/v2/internal_user_access_tokens_controller.rb b/app/controllers/api/v2/internal_user_access_tokens_controller.rb index 1b3058abc8..6cb19454ec 100644 --- a/app/controllers/api/v2/internal_user_access_tokens_controller.rb +++ b/app/controllers/api/v2/internal_user_access_tokens_controller.rb @@ -9,7 +9,7 @@ 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) + @v2_token = Api::V2::InternalUserAccessTokenService.rotate!(current_user) @success = true respond_to do |format| format.js { render 'users/refresh_token' } diff --git a/app/services/api/v2/internal_user_access_token_service.rb b/app/services/api/v2/internal_user_access_token_service.rb index 62bcb266c3..41f65397a8 100644 --- a/app/services/api/v2/internal_user_access_token_service.rb +++ b/app/services/api/v2/internal_user_access_token_service.rb @@ -24,24 +24,16 @@ class InternalUserAccessTokenService INTERNAL_OAUTH_APP_NAME = Rails.application.config.x.application.internal_oauth_app_name class << self - def for_user(user) - Doorkeeper::AccessToken.find_by( - application_id: application!.id, - resource_owner_id: user.id, - scopes: READ_SCOPE, - revoked_at: nil - ) - end - def rotate!(user) revoke_existing!(user) - Doorkeeper::AccessToken.create!( + 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 diff --git a/app/views/devise/registrations/_api_token.html.erb b/app/views/devise/registrations/_api_token.html.erb index 411747108e..783c3bc9f5 100644 --- a/app/views/devise/registrations/_api_token.html.erb +++ b/app/views/devise/registrations/_api_token.html.erb @@ -1,8 +1,9 @@ <%# locals: user %> +<% v2_token ||= nil %>
<%# v2 API token %> - <%= render partial: "devise/registrations/v2_api_token", locals: { user: user } %> + <%= render partial: "devise/registrations/v2_api_token", locals: { user: user, token: v2_token } %> <% if user.can_use_api? %> <%# v0/v1 API token %> diff --git a/app/views/devise/registrations/_v2_api_token.html.erb b/app/views/devise/registrations/_v2_api_token.html.erb index 6c8ed92556..55170549e2 100644 --- a/app/views/devise/registrations/_v2_api_token.html.erb +++ b/app/views/devise/registrations/_v2_api_token.html.erb @@ -1,4 +1,4 @@ -<%# locals: user %> +<%# locals: user, token %>
@@ -6,13 +6,19 @@
<% 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 %> + <%= token %> +
+ <%= _( "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") %> + <%= _( "Click the button below to generate an API token" ) %>
+
+ <%= _("If you previously generated and saved a token, please continue using that token.") %> +
<% end %>
diff --git a/app/views/users/refresh_token.js.erb b/app/views/users/refresh_token.js.erb index 242b51d346..76f409f940 100644 --- a/app/views/users/refresh_token.js.erb +++ b/app/views/users/refresh_token.js.erb @@ -1,6 +1,8 @@ +// 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-tokens'); -context.html('<%= escape_javascript(render partial: "/devise/registrations/api_token", locals: { user: current_user }) %>'); +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/spec/requests/api/v2/internal_user_access_tokens_controller_spec.rb b/spec/requests/api/v2/internal_user_access_tokens_controller_spec.rb index 5a624d5294..3a59becfb3 100644 --- a/spec/requests/api/v2/internal_user_access_tokens_controller_spec.rb +++ b/spec/requests/api/v2/internal_user_access_tokens_controller_spec.rb @@ -41,11 +41,11 @@ def post_create_token end.to change { Doorkeeper::AccessToken.count }.by(1) end - it 'assigns the token' do + it 'assigns the plaintext token' do post_create_token - expect(assigns(:token)).to be_a(Doorkeeper::AccessToken) - expect(assigns(:token).resource_owner_id).to eq(user.id) + expect(assigns(:v2_token)).to be_a(String) + expect(assigns(:v2_token)).not_to be_blank end it 'renders the refresh_token template' do 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 index 25e0622bfa..7455771740 100644 --- a/spec/services/api/v2/internal_user_access_token_service_spec.rb +++ b/spec/services/api/v2/internal_user_access_token_service_spec.rb @@ -11,56 +11,46 @@ 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 + 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') - return unless old_token - - expect(new_token).not_to eq(old_token) - expect(old_token.revoked_at).not_to be_nil + expect(old_token.revoked_at).not_to be_nil if old_token end - context 'when a token already exists' do - let!(:old_token) do - create_internal_user_access_token + 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 - 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 + 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 - it 'creates a new token' do - token = described_class.rotate!(user) - rotate_token_expectations(token) - end + include_examples 'token rotation', false 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 index e4323ff9df..e543ba83f7 100644 --- a/spec/views/devise/registrations/_api_token.html.erb_spec.rb +++ b/spec/views/devise/registrations/_api_token.html.erb_spec.rb @@ -6,11 +6,6 @@ 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) 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 index 3e904a1969..89ef408502 100644 --- a/spec/views/devise/registrations/_v2_api_token.html.erb_spec.rb +++ b/spec/views/devise/registrations/_v2_api_token.html.erb_spec.rb @@ -6,36 +6,32 @@ 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' 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) + 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!(:token) do - create(:oauth_access_token, - application: oauth_app, - resource_owner_id: user.id, - scopes: 'read') - end + let(:plaintext_token) { 'plaintext-token-value' } - it 'displays the token' do - render partial: 'devise/registrations/v2_api_token', locals: { user: user } - - expect(rendered).to have_selector('code', text: token.token) + 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 partial: 'devise/registrations/v2_api_token', locals: { user: user } - + 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 @@ -44,17 +40,15 @@ 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 } - + 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 partial: 'devise/registrations/v2_api_token', locals: { user: user } - - expect(rendered).not_to have_link('Regenerate token') + render_token_partial(token: nil) + expect(rendered).not_to have_selector('button', text: 'Regenerate token') expect(rendered).not_to have_selector('code') end end From 1928d53665d857a704801a851e5be053d31d31dc Mon Sep 17 00:00:00 2001 From: aaronskiba Date: Fri, 20 Feb 2026 09:56:19 -0700 Subject: [PATCH 14/19] Switch "Regenerate token" to button & prevent spamming Replace the "Regenerate token" link with a real `
- <%= link_to _("Regenerate token"), - api_v2_internal_user_access_token_path, - method: :post, - class: 'btn btn-secondary', - remote: true %> + <%= 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 } %>
<% else %>
From d06523e3d28f25571a13930c99a2b442f98ccf33 Mon Sep 17 00:00:00 2001 From: momo3404 <85097704+momo3404@users.noreply.github.com> Date: Wed, 25 Feb 2026 12:56:36 -0700 Subject: [PATCH 15/19] Add copy button to v2 api API Access update html --- .../devise/registrations/_v2_api_token.html.erb | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/app/views/devise/registrations/_v2_api_token.html.erb b/app/views/devise/registrations/_v2_api_token.html.erb index 8c5f94d6ce..9cd13287e7 100644 --- a/app/views/devise/registrations/_v2_api_token.html.erb +++ b/app/views/devise/registrations/_v2_api_token.html.erb @@ -9,7 +9,21 @@
<%= label_tag(:api_token, _('Access token'), class: 'form-label') %> <% if token.present? %> - <%= token %> + <%= 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." ) %> From f98d79c426bb3296df13827b2bc16a4a5bbd6e64 Mon Sep 17 00:00:00 2001 From: momo3404 <85097704+momo3404@users.noreply.github.com> Date: Wed, 25 Feb 2026 12:57:42 -0700 Subject: [PATCH 16/19] Add copyToken.js to allow for copying token - The button initially displays 'Copy', then a check mark after it is clicked for two seconds --- app/javascript/application.js | 1 + app/javascript/src/utils/copyToken.js | 35 +++++++++++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 app/javascript/src/utils/copyToken.js 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(); From af44c49a22dcf96d7bb954a3673ddf834c5830e5 Mon Sep 17 00:00:00 2001 From: momo3404 <85097704+momo3404@users.noreply.github.com> Date: Wed, 25 Feb 2026 13:23:14 -0700 Subject: [PATCH 17/19] Fix breaking tests in `_v2_api_token.html.erb_spec.rb` - 'code' was replaced by `api-token-val` --- .../views/devise/registrations/_v2_api_token.html.erb_spec.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 89ef408502..712431bd60 100644 --- a/spec/views/devise/registrations/_v2_api_token.html.erb_spec.rb +++ b/spec/views/devise/registrations/_v2_api_token.html.erb_spec.rb @@ -23,7 +23,7 @@ def render_token_partial(token: nil) 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).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 @@ -33,7 +33,7 @@ def render_token_partial(token: nil) 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') + expect(rendered).not_to have_selector('#api-token-val') end end end From b3bd4d84ab0c3f601f7f5b406da2d460a5d4b9db Mon Sep 17 00:00:00 2001 From: momo3404 <85097704+momo3404@users.noreply.github.com> Date: Thu, 5 Mar 2026 13:39:05 -0700 Subject: [PATCH 18/19] Add v2 API documentation to API Access page --- app/views/devise/registrations/_v2_api_token.html.erb | 9 +++++++++ config/initializers/_dmproadmap.rb | 3 ++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/app/views/devise/registrations/_v2_api_token.html.erb b/app/views/devise/registrations/_v2_api_token.html.erb index 9cd13287e7..cda26ede21 100644 --- a/app/views/devise/registrations/_v2_api_token.html.erb +++ b/app/views/devise/registrations/_v2_api_token.html.erb @@ -1,4 +1,5 @@ <%# locals: user, token %> +<% api_wikis = Rails.configuration.x.application.api_documentation_urls %>
@@ -43,6 +44,14 @@ 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.") %> diff --git a/config/initializers/_dmproadmap.rb b/config/initializers/_dmproadmap.rb index 92aca3d92d..cfc7a21687 100644 --- a/config/initializers/_dmproadmap.rb +++ b/config/initializers/_dmproadmap.rb @@ -79,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 = [ From 41f1f1537702e3d015f59609f392d1658229e816 Mon Sep 17 00:00:00 2001 From: momo3404 <85097704+momo3404@users.noreply.github.com> Date: Mon, 9 Mar 2026 13:12:25 -0600 Subject: [PATCH 19/19] Update documentation links for legacy APIs --- .../devise/registrations/_legacy_api_token.html.erb | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/app/views/devise/registrations/_legacy_api_token.html.erb b/app/views/devise/registrations/_legacy_api_token.html.erb index 0ef743f1de..8763eeb44c 100644 --- a/app/views/devise/registrations/_legacy_api_token.html.erb +++ b/app/views/devise/registrations/_legacy_api_token.html.erb @@ -17,9 +17,15 @@
<%= 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] } %> + <%= 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] + )%>

- <%= _('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' } %> + <%= 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"),