From 1e5e00e72700fb3363b8467dea10eca4797f4c5c Mon Sep 17 00:00:00 2001 From: aaronskiba Date: Fri, 6 Feb 2026 09:43:01 -0700 Subject: [PATCH 01/10] 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 | 20 ++++++++++++++++++++ 2 files changed, 22 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..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 From 4220103b370c33d51f21c859bcf4fe5cb90ef37c Mon Sep 17 00:00:00 2001 From: aaronskiba Date: Fri, 6 Feb 2026 09:50:14 -0700 Subject: [PATCH 02/10] 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 | 61 +++++++++++++++++++ 1 file changed, 61 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..93207eebd8 --- /dev/null +++ b/app/services/api/v2/internal_user_access_token_service.rb @@ -0,0 +1,61 @@ +# 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(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 + + private + + 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 From 2f9e5e2bb0c96d0ac8b44ef48895d445f927e24c Mon Sep 17 00:00:00 2001 From: aaronskiba Date: Fri, 6 Feb 2026 12:51:40 -0700 Subject: [PATCH 03/10] 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. --- .../internal_user_access_tokens_controller.rb | 19 +++++++++++++++++++ app/policies/user_policy.rb | 6 ++++++ config/routes.rb | 1 + 3 files changed, 26 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..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/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 e556374dc2a88c8e2ae15737006bd104672841e6 Mon Sep 17 00:00:00 2001 From: aaronskiba Date: Fri, 6 Feb 2026 16:22:27 -0700 Subject: [PATCH 04/10] 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 577da3bdd54f319ca101553cf16c29b52b3c230e Mon Sep 17 00:00:00 2001 From: aaronskiba Date: Fri, 6 Feb 2026 16:30:56 -0700 Subject: [PATCH 05/10] 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 15fb3ca256f4a21b4150c129bda0c373be90e414 Mon Sep 17 00:00:00 2001 From: aaronskiba Date: Mon, 9 Feb 2026 13:49:59 -0700 Subject: [PATCH 06/10] 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 6f09aab7327fe5c36804d183cc4e604e8e86886a Mon Sep 17 00:00:00 2001 From: aaronskiba Date: Mon, 9 Feb 2026 14:11:03 -0700 Subject: [PATCH 07/10] 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 1c4d6e2fa56bb1e8f6235062f0a6fa21157f68c4 Mon Sep 17 00:00:00 2001 From: aaronskiba Date: Thu, 12 Feb 2026 13:49:12 -0700 Subject: [PATCH 08/10] Add handling for missing internal OAuth app `InternalUserAccessTokenService`: add `application!` (memoized 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 | 26 +++++++++++-- .../registrations/_v2_api_token.html.erb | 39 +++++++++++-------- 2 files changed, 45 insertions(+), 20 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 93207eebd8..874d8db44e 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) @@ -39,8 +37,28 @@ 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! + @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)) @@ -50,7 +68,7 @@ def revoke_existing!(user) def user_token_filter(user) { resource_owner_id: user.id, - application_id: APPLICATION&.id, + application_id: application!.id, scopes: READ_SCOPE, revoked_at: nil } 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 6f83eadb57922577322bda0aef4ae391efa15c72 Mon Sep 17 00:00:00 2001 From: aaronskiba Date: Fri, 13 Feb 2026 14:17:10 -0700 Subject: [PATCH 09/10] 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 | 96 +++++++++++++++++++ ...internal_user_access_token_service_spec.rb | 87 +++++++++++++++++ .../registrations/_api_token.html.erb_spec.rb | 35 +++++++ .../_v2_api_token.html.erb_spec.rb | 66 +++++++++++++ 4 files changed, 284 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..64f06daf36 --- /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(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..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..df5deb24cf --- /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(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 23a223042724881f231e48e3ae6318defa02013a Mon Sep 17 00:00:00 2001 From: aaronskiba Date: Fri, 13 Feb 2026 14:21:43 -0700 Subject: [PATCH 10/10] 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 64f06daf36..84cd848546 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 @@ -14,7 +14,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 df5deb24cf..ae52746977 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 @@ -18,7 +18,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