Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
66ed567
Create internal Doorkeeper app via rake task
aaronskiba Feb 6, 2026
e7524cc
Create `Api::V2::InternalUserAccessTokenService`
aaronskiba Feb 6, 2026
7067405
Add "POST /api/v2/internal_user_access_token" action & route
aaronskiba Feb 6, 2026
437a9ed
Add API v2 section to `/users/edit#api-details`
aaronskiba Feb 6, 2026
1c3e932
Refactor api_token into v2 & legacy partials
aaronskiba Feb 6, 2026
bd16be5
Expose API Access tab to all users / restrict legacy token rendering
aaronskiba Feb 9, 2026
38b1437
Improve styling for v2 + legacy API displays
aaronskiba Feb 9, 2026
1071383
Add handling for missing internal OAuth app
aaronskiba Feb 12, 2026
bbe35e4
Add test coverage for internal v2 token generation
aaronskiba Feb 13, 2026
81ca29b
Set default format for internal_user_access_token route
aaronskiba Feb 13, 2026
3601651
Fix string typo in `refresh_token.js.erb`
aaronskiba Feb 17, 2026
b5e9c43
Update API Access UI to BS3 (revert after BS5 upgrade)
aaronskiba Feb 17, 2026
1e192a1
Merge branch 'aaron/v2-api' into aaron/feature-v2-api-token-for-inter…
aaronskiba Feb 19, 2026
34815ef
Remove `redirect_uri` from internal OAuth app
aaronskiba Feb 19, 2026
2e8c176
Merge branch 'aaron/v2-api' into aaron/feature-v2-api-token-for-inter…
aaronskiba Feb 19, 2026
16c72d4
Adapt internal user v2 token handling to hashed tokens
aaronskiba Feb 20, 2026
3a39867
Switch "Regenerate token" to button & prevent spamming
aaronskiba Feb 20, 2026
f63abd1
Improve styling for API titles in partials
aaronskiba Feb 20, 2026
8802049
Update CHANGELOG.md
aaronskiba Feb 23, 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?
@token = Api::V2::InternalUserAccessTokenService.rotate!(current_user)
@success = true
respond_to do |format|
format.js { render 'users/refresh_token' }
end
end
end
end
end
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
74 changes: 74 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,74 @@
# frozen_string_literal: true

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

class << self
def for_user(user)
Doorkeeper::AccessToken.find_by(
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`
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For security purposes, maybe we should still set some expiry for these tokens?
Also, in config/initializers/doorkeeper.rb, we currently have access_token_expires_in 2.hours. Maybe this token expiry should be extended?

)
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
30 changes: 8 additions & 22 deletions app/views/devise/registrations/_api_token.html.erb
Original file line number Diff line number Diff line change
@@ -1,25 +1,11 @@
<%# locals: user %>

<% api_wikis = Rails.configuration.x.application.api_documentation_urls %>
<div id="api-token" class="col-xs-12">
<div class="form-group col-xs-8">
<%= label_tag(:api_token, _('Access token'), class: 'control-label') %>
<% if user.api_token.present? %>
<%= user.api_token %>
<% else %>
<%= _("Click the button below to generate an API token") %>
<% end %>
</div>
<div class="form-group col-xs-12">
<%= label_tag(:api_information, _('Documentation'), class: 'control-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-group col-xs-8">
<%= link_to _("Regenerate token"),
refresh_token_user_path(user),
class: "btn btn-default", remote: true %>
</div>
<div id="api-tokens">
<%# v2 API token %>
<%= render partial: "devise/registrations/v2_api_token", locals: { user: user } %>

<% if user.can_use_api? %>
<%# v0/v1 API token %>
<%= render partial: "devise/registrations/legacy_api_token", locals: { user: user } %>
<% end %>
</div>
30 changes: 30 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,30 @@
<%# locals: user %>

<% api_wikis = Rails.configuration.x.application.api_documentation_urls %>
<div id="legacy-api-token" class="panel mb-4" style="border: 1px solid #0a0a0a">
<div class="nav nav-tabs">
<%= _('Legacy API') %>
</div>
<div class="panel-body">
<div class="form-group 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-group 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-group mb-3 col-xs-8">
<%= link_to _("Regenerate token"),
refresh_token_user_path(user),
class: "btn btn-default", remote: true %>
</div>
</div>
</div>
33 changes: 33 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,33 @@
<%# locals: user %>

<div id="v2-api-token" class="panel mb-4" style="border: 1px solid #0a0a0a">
<div class="nav nav-tabs">
<%= _('V2 API') %>
</div>
<div class="panel-body">
<% if Api::V2::InternalUserAccessTokenService.application_present? %>
<% token = Api::V2::InternalUserAccessTokenService.for_user(user) %>
<div class="form-group mb-3 col-xs-8">
<%= label_tag(:api_token, _('Access token'), class: 'form-label') %>
<% if token.present? %>
<code><%= token.token %></code>
<% else %>
<%= _("Click the button below to generate an API token") %>
<% end %>
</div>

<div class="form-group mb-3 col-xs-8">
<%= link_to _("Regenerate token"),
api_v2_internal_user_access_token_path,
method: :post,
class: 'btn btn-default',
remote: true %>
</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-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-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-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-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="panel panel-default">
<div class="panel-body">
<%= render partial: 'devise/registrations/api_token', locals: { user: @user } %>
</div>
<div id="api-details" role="tabpanel" class="tab-pane">
<div class="panel panel-default">
<div class="panel-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="panel panel-default">
<div class="panel-body">
Expand Down
4 changes: 2 additions & 2 deletions app/views/users/refresh_token.js.erb
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
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-token');
var context = $('#api-tokens');
context.html('<%= escape_javascript(render partial: "/devise/registrations/api_token", locals: { user: current_user }) %>');
renderNotice(msg);
toggleSpinner(false);
2 changes: 2 additions & 0 deletions config/initializers/_dmproadmap.rb
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ class Application < Rails::Application

# Used throughout the system via ApplicationService.application_name
config.x.application.name = 'DMP Assistant'
# 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 `[email protected]` becomes `1234@removed_accounts-example.org`
config.x.application.archived_accounts_email_suffix = '@removed_accounts-example.org'
Expand Down
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,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
19 changes: 19 additions & 0 deletions lib/tasks/doorkeeper.rake
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# frozen_string_literal: true

require 'rails_helper'

RSpec.describe Api::V2::InternalUserAccessTokensController do
let(:user) { create(:user) }
let(:app_name) { Rails.application.config.x.application.internal_oauth_app_name }
let!(:oauth_app) { create(:oauth_application, name: app_name) }

describe 'POST #create' do
def post_create_token
post api_v2_internal_user_access_token_path
end

context 'when user is not authenticated' do
# In production, CSRF protection would reject the request with a 422 error
# before it reaches Pundit. However, RSpec bypasses CSRF checks, so this
# test verifies that Pundit raises NotDefinedError when authorize is called
# with nil. This error won't occur in production due to CSRF protection.
it 'raises Pundit::NotDefinedError and does not create a token' do
expect do
expect do
post_create_token
end.to raise_error(Pundit::NotDefinedError)
end.not_to change { Doorkeeper::AccessToken.count }
end
end

context 'when user is authenticated' do
before { sign_in(user) }

it 'rotates the user token' do
post_create_token

expect(response).to have_http_status(:ok)
end

it 'creates a new token' do
expect do
post_create_token
end.to change { Doorkeeper::AccessToken.count }.by(1)
end

it 'assigns the 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
Loading