Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
e46519f
Create internal Doorkeeper app via rake task
aaronskiba Feb 6, 2026
ccf0f92
Create `Api::V2::InternalUserAccessTokenService`
aaronskiba Feb 6, 2026
797aeb6
Add "POST /api/v2/internal_user_access_token" action & route
aaronskiba Feb 6, 2026
965896b
Add API v2 section to `/users/edit#api-details`
aaronskiba Feb 6, 2026
4bbf690
Refactor api_token into v2 & legacy partials
aaronskiba Feb 6, 2026
3782f02
Expose API Access tab to all users / restrict legacy token rendering
aaronskiba Feb 9, 2026
b4fc307
Improve styling for v2 + legacy API displays
aaronskiba Feb 9, 2026
5436190
Add handling for missing internal OAuth app
aaronskiba Feb 12, 2026
8ef06e2
Add test coverage for internal v2 token generation
aaronskiba Feb 13, 2026
6a54ad5
Set default format for internal_user_access_token route
aaronskiba Feb 13, 2026
6fd8a06
Fix string typo in `refresh_token.js.erb`
aaronskiba Feb 17, 2026
e315749
Merge branch 'api_v2_dmponline' into aaron/feature/v2-api-token-for-i…
aaronskiba Feb 19, 2026
9904d76
Remove `redirect_uri` from internal OAuth app
aaronskiba Feb 19, 2026
fdae153
Merge branch 'api_v2_dmponline' into aaron/feature/v2-api-token-for-i…
aaronskiba Mar 12, 2026
8cb9227
Adapt internal user v2 token handling to hashed tokens
aaronskiba Feb 20, 2026
1928d53
Switch "Regenerate token" to button & prevent spamming
aaronskiba Feb 20, 2026
d06523e
Add copy button to v2 api API Access
momo3404 Feb 25, 2026
f98d79c
Add copyToken.js to allow for copying token
momo3404 Feb 25, 2026
af44c49
Fix breaking tests in `_v2_api_token.html.erb_spec.rb`
momo3404 Feb 25, 2026
b3bd4d8
Add v2 API documentation to API Access page
momo3404 Mar 5, 2026
41f1f15
Update documentation links for legacy APIs
momo3404 Mar 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions app/controllers/api/v2/internal_user_access_tokens_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# frozen_string_literal: true

module Api
module V2
# Controller for managing the current user's internal V2 API access token.
# Provides token rotation for authenticated internal users.
# See Api::V2::InternalUserAccessTokenService for token implementation details.
class InternalUserAccessTokensController < ApplicationController
# POST "/api/v2/internal_user_access_token"
def create
authorize current_user, :internal_user_v2_access_token?
@v2_token = Api::V2::InternalUserAccessTokenService.rotate!(current_user)
@success = true
respond_to do |format|
format.js { render 'users/refresh_token' }
end
end
end
end
end
1 change: 1 addition & 0 deletions app/javascript/application.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
35 changes: 35 additions & 0 deletions app/javascript/src/utils/copyToken.js
Original file line number Diff line number Diff line change
@@ -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 = '<i class="fa fa-circle-check" aria-hidden="true"></i>';

// Restore after 2s
setTimeout(() => {
button.innerHTML = originalHTML;
button.disabled = false;
}, 2000);
}).catch(() => {
button.disabled = false;
alert('Failed to copy token');
});
});
};

initCopyToken();
6 changes: 6 additions & 0 deletions app/policies/user_policy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
66 changes: 66 additions & 0 deletions app/services/api/v2/internal_user_access_token_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# frozen_string_literal: true

module Api
module V2
# Service responsible for user-scoped v2 API access tokens, strictly for
# internal users of this application.
#
# Tokens issued by this service are functionally equivalent to Personal Access
# Tokens (PATs) for first-party usage. They are minted directly for a user
# who is already authenticated in the application, bypassing the standard
# OAuth 2.0 authorization_code redirect and consent flow.
#
# This design is intentional:
# - tokens are internal to this application (first-party)
# - tokens are owned by a single user and scoped accordingly
# - token creation, rotation, and revocation happen entirely within the app UI
#
# Tokens are stored as Doorkeeper::AccessToken records to leverage existing
# scoping, expiry, and revocation mechanisms.
#
# This service does NOT support third-party OAuth clients or delegated consent flows.
class InternalUserAccessTokenService
READ_SCOPE = 'read'
INTERNAL_OAUTH_APP_NAME = Rails.application.config.x.application.internal_oauth_app_name

class << self
def rotate!(user)
revoke_existing!(user)

token = Doorkeeper::AccessToken.create!(
application_id: application!.id,
resource_owner_id: user.id,
scopes: READ_SCOPE,
expires_in: nil # Overrides Doorkeeper's `access_token_expires_in`
)
token.plaintext_token
end

# Used by views (e.g. devise/registrations/_v2_api_token.html.erb) to safely
# gate token UI if the internal OAuth application is missing.
def application_present?
application!
true
rescue StandardError => e
Rails.logger.error(e.message)
false
end

private

def application!
Doorkeeper::Application.find_by(name: INTERNAL_OAUTH_APP_NAME) ||
raise(
StandardError,
"Required Doorkeeper application '#{INTERNAL_OAUTH_APP_NAME}' not found. " \
'Please ensure the application exists in the database.'
)
end

def revoke_existing!(user)
Doorkeeper::AccessToken.revoke_all_for(application!.id, user)
end
end
end
end
end
31 changes: 9 additions & 22 deletions app/views/devise/registrations/_api_token.html.erb
Original file line number Diff line number Diff line change
@@ -1,25 +1,12 @@
<%# locals: user %>
<% v2_token ||= nil %>

<% api_wikis = Rails.configuration.x.application.api_documentation_urls %>
<div id="api-token" class="col-xs-12">
<div class="form-control mb-3 col-xs-8">
<%= 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 %>
</div>
<div class="form-control mb-3 col-xs-12">
<%= label_tag(:api_information, _('Documentation'), class: 'form-label') %>
<br>
<%= _('See the <a href="%{api_v0_wiki}">documentation for v0</a> 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] } %></a>
<br><br>
<%= _('See the <a href="%{api_v1_wiki}">documentation for v1</a> for more details on the API that supports the <a href="%{rda_standard_url}">RDA Common metadata standard for DMPs.</a>').html_safe % { api_v1_wiki: api_wikis[:v1], rda_standard_url: 'https://github.com/RDA-DMP-Common/RDA-DMP-Common-Standard' } %></a>
</div>
<div class="form-control mb-3 col-xs-8">
<%= link_to _("Regenerate token"),
refresh_token_user_path(user),
class: "btn btn-secondary", remote: true %>
</div>
<div id="api-tokens">
<%# v2 API token %>
<%= render partial: "devise/registrations/v2_api_token", locals: { user: user, token: v2_token } %>

<% if user.can_use_api? %>
<%# v0/v1 API token %>
<%= render partial: "devise/registrations/legacy_api_token", locals: { user: user } %>
<% end %>
</div>
36 changes: 36 additions & 0 deletions app/views/devise/registrations/_legacy_api_token.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<%# locals: user %>

<% api_wikis = Rails.configuration.x.application.api_documentation_urls %>
<div id="legacy-api-token" class="card mb-4">
<div class="card-heading">
<%= _('Legacy API') %>
</div>
<div class="card-body">
<div class="form-control mb-3 col-xs-8">
<%= label_tag(:api_token, _('Access token'), class: 'form-label') %>
<% if user.api_token.present? %>
<code><%= user.api_token %></code>
<% else %>
<%= _("Click the button below to generate an API token") %>
<% end %>
</div>
<div class="form-control mb-3 col-xs-12">
<%= label_tag(:api_information, _('Documentation'), class: 'form-label') %>
<br>
<%= sanitize(_('See the <a href="%{api_v0_wiki}">documentation for v0</a> 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]
)%>
<br><br>
<%= sanitize(_('See the <a href="%{api_v1_wiki}">documentation for v1</a> for more details on the API that supports the <a href="%{rda_standard_url}">RDA Common metadata standard for DMPs.</a>') %
{ api_v1_wiki: api_wikis[:v1], rda_standard_url: 'https://github.com/RDA-DMP-Common/RDA-DMP-Common-Standard' },
attributes: %w[href]
)%>
</div>
<div class="form-control mb-3 col-xs-8">
<%= link_to _("Regenerate token"),
refresh_token_user_path(user),
class: "btn btn-secondary", remote: true %>
</div>
</div>
</div>
62 changes: 62 additions & 0 deletions app/views/devise/registrations/_v2_api_token.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<%# locals: user, token %>
<% api_wikis = Rails.configuration.x.application.api_documentation_urls %>

<div id="v2-api-token" class="card mb-4">
<div class="card-heading">
<%= _('V2 API') %>
</div>
<div class="card-body">
<% if Api::V2::InternalUserAccessTokenService.application_present? %>
<div class="form-control mb-3 col-xs-8">
<%= label_tag(:api_token, _('Access token'), class: 'form-label') %>
<% if token.present? %>
<%= text_field_tag(
:api_token_val,
token,
id: 'api-token-val',
class: 'form-control',
style: 'width: auto;',
readonly: true
) %>

<%= button_tag(
_('Copy'),
id: 'copy-token-btn',
type: 'button',
class: 'btn btn-secondary'
) %>
<div class="alert alert-warning">
<%= _( "Please copy this token now and store it somewhere safely." ) %><br>
<%= _( "It will disappear after you leave or refresh this page." ) %>
</div>
<% else %>
<%= _( "Click the button below to generate an API token" ) %><br>
<div class="alert alert-warning">
<%= _("If you previously generated and saved a token, please continue using that token.") %>
</div>
<% end %>
</div>

<div class="form-control mb-3 col-xs-8">
<%= 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 } %>
</div>
<div class="form-group mb-3 col-xs-12">
<%= label_tag(:api_information, _('Documentation'), class: 'form-label') %>
<br>
<%= sanitize(_('See the <a href="%{api_v2_wiki}">documentation for v2</a> for more details on the API.') %
{ api_v2_wiki: api_wikis[:v2] },
attributes: %w[href]
)%>
</div>
<% else %>
<div class="alert alert-warning">
<%= _("V2 API token service is currently unavailable. Please contact us for help.") %>
<%= mail_to Rails.application.config.x.organisation.helpdesk_email %>
</div>
<% end %>
</div>
</div>
22 changes: 9 additions & 13 deletions app/views/devise/registrations/edit.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,10 @@
<a href="#password-details" role="tab" class="nav-link"
aria-controls="password-details" data-bs-toggle="tab"><%= _('Password') %></a>
</li>
<% if @user.can_use_api? %>
<li role="api-details" class="nav-item">
<a href="#api-details" role="tab" class="nav-link"
aria-controls="api-details" data-bs-toggle="tab"><%= _('API Access') %></a>
</li>
<% end %>
<li role="api-details" class="nav-item">
<a href="#api-details" role="tab" class="nav-link"
aria-controls="api-details" data-bs-toggle="tab"><%= _('API Access') %></a>
</li>
<li role="notification-preferences" class="nav-item">
<a href="#notification-preferences" role="tab" class="nav-link"
aria-controls="notification-preferences" data-bs-toggle="tab"><%= _('Notification Preferences') %></a>
Expand All @@ -43,15 +41,13 @@
</div>
</div>
</div>
<% if @user.can_use_api? %>
<div id="api-details" role="tabpanel" class="tab-pane">
<div class="card card-default">
<div class="card-body">
<%= render partial: 'devise/registrations/api_token', locals: { user: @user } %>
</div>
<div id="api-details" role="tabpanel" class="tab-pane">
<div class="card card-default">
<div class="card-body">
<%= render partial: 'devise/registrations/api_token', locals: { user: @user } %>
</div>
</div>
<% end %>
</div>
<div id="notification-preferences" role="tabpanel" class="tab-pane">
<div class="card card-default">
<div class="card-body">
Expand Down
8 changes: 5 additions & 3 deletions app/views/users/refresh_token.js.erb
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
var msg = '<%= @success ? _("Successfully regenerate your API token.") : _("Unable to regenerate your API token.") %>';
// This view is called by both InternalUserAccessTokensController#create (provides @v2_token)
// and UsersController#refresh_token (does not provide @v2_token).
var msg = '<%= @success ? _("Successfully regenerated your API token.") : _("Unable to regenerate your API token.") %>';

var context = $('#api-token');
context.html('<%= escape_javascript(render partial: "/devise/registrations/api_token", locals: { user: current_user }) %>');
var context = $('#api-tokens');
context.html('<%= escape_javascript(render partial: "/devise/registrations/api_token", locals: { user: current_user, v2_token: @v2_token }) %>');
renderNotice(msg);
toggleSpinner(false);
5 changes: 4 additions & 1 deletion config/initializers/_dmproadmap.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -77,7 +79,8 @@ class Application < Rails::Application
# The link to the API documentation - used in emails about the API
config.x.application.api_documentation_urls = {
v0: 'https://github.com/DMPRoadmap/roadmap/wiki/API-V0-Documentation',
v1: 'https://github.com/DMPRoadmap/roadmap/wiki/API-V1-Documentation'
v1: 'https://github.com/DMPRoadmap/roadmap/wiki/API-v1-Documentation',
v2: 'https://github.com/DMPRoadmap/roadmap/wiki/API-v2-Documentation'
}
# The links that appear on the home page. Add any number of links
config.x.application.welcome_links = [
Expand Down
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@

resources :plans, only: %i[index show]
resources :templates, only: :index
resource :internal_user_access_token, only: :create, defaults: { format: :js }
end
end

Expand Down
15 changes: 15 additions & 0 deletions lib/tasks/doorkeeper.rake
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# frozen_string_literal: true

namespace :doorkeeper do
desc 'Ensure internal OAuth application exists'
task ensure_internal_app: :environment do
app = Doorkeeper::Application.find_or_create_by!(
name: Rails.application.config.x.application.internal_oauth_app_name
) do |a|
a.scopes = 'read'
a.confidential = true
end

puts "Internal OAuth app ready (id=#{app.id}, uid=#{app.uid})"
end
end
Loading
Loading