feat: credential rotation for OAuth apps (#178)

This commit is contained in:
End 2026-02-26 14:42:51 -07:00 committed by GitHub
parent 73f87f0f9f
commit e04d3e1119
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 58 additions and 10 deletions

View file

@ -1,5 +1,5 @@
class Backend::ProgramsController < Backend::ApplicationController class Backend::ProgramsController < Backend::ApplicationController
before_action :set_program, only: [ :show, :edit, :update, :destroy ] before_action :set_program, only: [ :show, :edit, :update, :destroy, :rotate_credentials ]
hint :list_navigation, on: :index hint :list_navigation, on: :index
hint :back_navigation, on: :index hint :back_navigation, on: :index
@ -61,6 +61,13 @@ class Backend::ProgramsController < Backend::ApplicationController
redirect_to backend_programs_path, notice: "Program was successfully deleted." redirect_to backend_programs_path, notice: "Program was successfully deleted."
end end
def rotate_credentials
authorize @program
@program.rotate_credentials!
redirect_to backend_program_path(@program), notice: "Credentials have been rotated. Make sure to update any integrations using the old secret/API key."
end
private private
def set_program def set_program

View file

@ -1,6 +1,6 @@
class DeveloperAppsController < ApplicationController class DeveloperAppsController < ApplicationController
before_action :require_developer_mode before_action :require_developer_mode
before_action :set_app, only: [ :show, :edit, :update, :destroy ] before_action :set_app, only: [ :show, :edit, :update, :destroy, :rotate_credentials ]
def index def index
@apps = current_identity.owned_developer_apps.order(created_at: :desc) @apps = current_identity.owned_developer_apps.order(created_at: :desc)
@ -43,6 +43,11 @@ class DeveloperAppsController < ApplicationController
redirect_to developer_apps_path, notice: t(".success"), status: :see_other redirect_to developer_apps_path, notice: t(".success"), status: :see_other
end end
def rotate_credentials
@app.rotate_credentials!
redirect_to developer_app_path(@app), notice: t(".success")
end
private private
def require_developer_mode def require_developer_mode

View file

@ -2,7 +2,7 @@ class Identity::ReapAgedOutUsersJob < ApplicationJob
queue_as :default queue_as :default
def perform def perform
aged_out = Identity.where(ysws_eligible: true, hq_override: [false, nil]) aged_out = Identity.where(ysws_eligible: true, hq_override: [ false, nil ])
.where("birthday <= ?", 19.years.ago.to_date) .where("birthday <= ?", 19.years.ago.to_date)
reaped_count = 0 reaped_count = 0
@ -15,4 +15,4 @@ class Identity::ReapAgedOutUsersJob < ApplicationJob
Rails.logger.info "ReapAgedOutUsersJob: marked #{reaped_count} #{"user".pluralize reaped_count} as alumni and ineligible" Rails.logger.info "ReapAgedOutUsersJob: marked #{reaped_count} #{"user".pluralize reaped_count} as alumni and ineligible"
end end
end end

View file

@ -95,6 +95,12 @@ class Program < ApplicationRecord
onboarding_scenario_class&.new(identity) onboarding_scenario_class&.new(identity)
end end
def rotate_credentials!
self.secret = SecureRandom.hex(32)
self.program_key = "prgmk." + SecureRandom.hex(32)
save!
end
def self.find_by_redirect_uri_host(url) def self.find_by_redirect_uri_host(url)
return nil if url.blank? return nil if url.blank?
begin begin

View file

@ -15,6 +15,8 @@ class ProgramPolicy < ApplicationPolicy
def update_onboarding_scenario? = user&.super_admin? def update_onboarding_scenario? = user&.super_admin?
def rotate_credentials? = user_is_program_manager?
class Scope < Scope class Scope < Scope
def resolve def resolve
if user.program_manager? || user.super_admin? if user.program_manager? || user.super_admin?

View file

@ -134,7 +134,7 @@ class AnalyticsService
totals.map do |scenario, total| totals.map do |scenario, total|
prom = promoted[scenario] || 0 prom = promoted[scenario] || 0
rate = total > 0 ? ((prom.to_f / total) * 100).round(1) : 0 rate = total > 0 ? ((prom.to_f / total) * 100).round(1) : 0
[scenario || "default", { total: total, promoted: prom, rate: rate }] [ scenario || "default", { total: total, promoted: prom, rate: rate } ]
end.sort_by { |_, v| -v[:total] }.to_h end.sort_by { |_, v| -v[:total] }.to_h
end end

View file

@ -60,6 +60,11 @@
<label>api key</label> <label>api key</label>
<input type="text" value="<%= @program.program_key %>" readonly onclick="this.select()" data-click-to-copy autocomplete="off"> <input type="text" value="<%= @program.program_key %>" readonly onclick="this.select()" data-click-to-copy autocomplete="off">
</div> </div>
<% if policy(@program).rotate_credentials? %>
<div class="form-row" style="margin-top: 0.5rem;">
<%= link_to "rotate secret & api key", rotate_credentials_backend_program_path(@program), method: :post, data: { confirm: "are you sure? this will invalidate the current secret and api key. any integrations using them will break." }, class: "btn btn-danger" %>
</div>
<% end %>
</div> </div>
</div> </div>
<% if @program.redirect_uri.present? %> <% if @program.redirect_uri.present? %>

View file

@ -37,8 +37,13 @@
<% end %> <% end %>
<small class="usn" style="display: block; margin-top: 0.25rem;"><%= t ".auth_link_hint" %></small> <small class="usn" style="display: block; margin-top: 0.25rem;"><%= t ".auth_link_hint" %></small>
</div> </div>
</div> <div>
</section> <%= button_to t(".rotate_credentials"), rotate_credentials_developer_app_path(@app), method: :post, class: "danger small-btn",
form: { "hx-confirm": t(".rotate_confirm") } %>
<small class="usn" style="display: block; margin-top: 0.25rem;"><%= t ".rotate_hint" %></small>
</div>
</div>
</section>
<section class="section-card" style="margin-bottom: 1.5rem;"> <section class="section-card" style="margin-bottom: 1.5rem;">
<h3><%= t ".configuration" %></h3> <h3><%= t ".configuration" %></h3>

View file

@ -157,6 +157,9 @@ en:
edit: Edit edit: Edit
delete: Delete delete: Delete
delete_confirm: Are you sure you want to delete this app? This cannot be undone. delete_confirm: Are you sure you want to delete this app? This cannot be undone.
rotate_credentials: Rotate Secret & API Key
rotate_confirm: Are you sure? This will invalidate your current client secret and API key, breaking any existing integrations using those credentials.
rotate_hint: If your credentials have been compromised, you can rotate them to generate new ones. Just make sure to update your integration with the new credentials afterward!
client_id: Client ID client_id: Client ID
click_to_copy_client_id: click to copy client ID click_to_copy_client_id: click to copy client ID
blank_slate: blank_slate:
@ -183,6 +186,9 @@ en:
edit_app: Edit App edit_app: Edit App
delete_app: Delete App delete_app: Delete App
delete_confirm: Are you sure you want to delete this app? This action cannot be undone and will revoke all existing tokens. delete_confirm: Are you sure you want to delete this app? This action cannot be undone and will revoke all existing tokens.
rotate_credentials: Rotate Secret & API Key
rotate_confirm: Are you sure? This will generate a new client secret and API key. The old ones will stop working immediately.
rotate_hint: If your credentials have been compromised, rotate them here.
new: new:
title: Create New OAuth App title: Create New OAuth App
back_to_apps: ← Back to Apps back_to_apps: ← Back to Apps
@ -218,6 +224,8 @@ en:
success: OAuth app updated successfully! success: OAuth app updated successfully!
destroy: destroy:
success: OAuth app deleted successfully! success: OAuth app deleted successfully!
rotate_credentials:
success: OAuth app credentials rotated successfully!
require_developer_mode: require_developer_mode:
developer_mode_required: Developer mode is not enabled for your account. developer_mode_required: Developer mode is not enabled for your account.
set_app: set_app:
@ -671,4 +679,4 @@ en:
delete_confirm: Are you sure you want to delete this address? delete_confirm: Are you sure you want to delete this address?
add_new: Add a new address add_new: Add a new address
add_button: Add Address add_button: Add Address
done: Done done: Done

View file

@ -226,7 +226,12 @@ Rails.application.routes.draw do
end end
end end
resources :programs resources :programs do
member do
post :rotate_credentials
end
end
post "/break_glass", to: "break_glass#create" post "/break_glass", to: "break_glass#create"
@ -356,7 +361,12 @@ Rails.application.routes.draw do
resources :authorized_applications, only: [ :index, :destroy ] resources :authorized_applications, only: [ :index, :destroy ]
resources :developer_apps, path: "developer/apps" resources :developer_apps, path: "developer/apps" do
member do
post :rotate_credentials
end
end
namespace :api do namespace :api do
namespace :v1 do namespace :v1 do