Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion app/controllers/api/v1/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ def authenticate!
@current_program = @current_token.application
@current_scopes = @current_token.scopes
unless @current_program&.active?
return render json: { error: "invalid_auth" }, status: :unauthorized
render json: { error: "invalid_auth" }, status: :unauthorized
end
else
unless @current_token.hq_official?
Expand Down
46 changes: 33 additions & 13 deletions app/controllers/authorized_applications_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,49 @@ class AuthorizedApplicationsController < ApplicationController
include AhoyAnalytics

def index
@access_tokens = current_identity.access_tokens
.includes(:application)
.order(created_at: :desc)

render layout: request.headers["HX-Request"] ? "htmx" : false
@authorized_apps = load_apps
render layout: htmx? ? "htmx" : false
end

def destroy
token = current_identity.access_tokens.find(params[:id])
track_event("oauth.revoked", program_name: token.application&.name, program_id: token.application&.id, scenario: token.application&.onboarding_scenario)
token.revoke
token.create_activity :revoke, owner: current_identity, recipient: current_identity
t = current_identity.access_tokens.find(params[:id])
app = t.application

track_event("oauth.revoked", program_name: app&.name, program_id: app&.id, scenario: app&.onboarding_scenario)

if request.headers["HX-Request"]
@access_tokens = current_identity.access_tokens
.includes(:application)
.order(created_at: :desc)
n = current_identity.all_access_tokens.where(application_id: app.id, revoked_at: nil).count
Program.revoke_tokens_and_grants_for(app.id, current_identity)
t.create_activity :revoke, owner: current_identity, recipient: current_identity,
parameters: { application_id: app.id, tokens_revoked: n }

if htmx?
@authorized_apps = load_apps
flash.now[:success] = "Application access revoked successfully"
render :index, layout: "htmx"
else
flash[:success] = "Application access revoked."
redirect_to security_path
end
end

private

def htmx? = request.headers["HX-Request"].present?

def load_apps
current_identity.access_tokens.includes(:application).order(created_at: :desc).to_a
.group_by(&:application_id)
.filter_map { |_, toks|
tok = toks.first
next unless tok&.application
{
token: tok,
application: tok.application,
authorized_at: toks.map(&:created_at).min,
expires_at: toks.filter_map(&:expires_at).min,
scopes: toks.flat_map { |x| x.scopes.to_a }.uniq
}
}
.sort_by { |e| e[:authorized_at] }.reverse
end
end
2 changes: 1 addition & 1 deletion app/controllers/identity_backup_codes_controller.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
class IdentityBackupCodesController < ApplicationController
before_action -> { require_step_up("regenerate_backup_codes", return_to: identity_backup_codes_path) }, only: [:create, :confirm]
before_action -> { require_step_up("regenerate_backup_codes", return_to: identity_backup_codes_path) }, only: [ :create, :confirm ]

def index
@backup_codes = current_identity.backup_codes.active.order(created_at: :desc)
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/identity_totps_controller.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
class IdentityTotpsController < ApplicationController
before_action -> { require_step_up("add_totp", return_to: security_path) }, only: [:new, :verify]
before_action -> { require_step_up("add_totp", return_to: security_path) }, only: [ :new, :verify ]

def index
@totp = current_identity.totp
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
class IdentityWebauthnCredentialsController < ApplicationController
include WebauthnAuthenticatable

before_action -> { require_step_up("add_passkey", return_to: security_path) }, only: [:new, :options, :create]
before_action -> { require_step_up("add_passkey", return_to: security_path) }, only: [ :new, :options, :create ]

def index
@webauthn_credentials = current_identity.webauthn_credentials.order(created_at: :desc)
Expand Down
4 changes: 2 additions & 2 deletions app/controllers/step_up_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ class StepUpController < ApplicationController
VALID_ACTIONS = %w[remove_totp disable_2fa oidc_reauth email_change remove_passkey add_passkey regenerate_backup_codes add_totp].freeze
ACTIONS_WITHOUT_EMAIL_FALLBACK = %w[email_change disable_2fa remove_passkey].freeze

before_action :validate_action_type, except: [:webauthn_options]
before_action :require_pending_step_up, only: [:send_email_code, :verify, :resend_email]
before_action :validate_action_type, except: [ :webauthn_options ]
before_action :require_pending_step_up, only: [ :send_email_code, :verify, :resend_email ]

def new
session[:pending_step_up_action] = params[:action_type]
Expand Down
24 changes: 11 additions & 13 deletions app/views/authorized_applications/_authorized_application.html.erb
Original file line number Diff line number Diff line change
@@ -1,41 +1,39 @@
<% app, tok, scopes = e.values_at(:application, :token, :scopes) %>
<div class="card-hoverable">
<div class="session-header">
<div class="session-icon"><%= inline_icon("private", size: 20) %></div>
<div class="session-info">
<div class="session-device">
<%= access_token.application.name %>
</div>
<div class="session-device"><%= app.name %></div>
</div>
</div>

<div class="session-details">
<div>
<span class="detail-label"><%= t "authorized_applications.created_at" %></span>
<span><%= access_token.created_at.strftime("%b %d, %Y at %l:%M %p") %></span>
<span><%= e[:authorized_at].strftime("%b %d, %Y at %l:%M %p") %></span>
</div>
<% if access_token.expires_at %>
<% if e[:expires_at] %>
<div>
<span class="detail-label"><%= t "authorized_applications.expires_at" %></span>
<span><%= access_token.expires_at.strftime("%b %d, %Y") %></span>
<span><%= e[:expires_at].strftime("%b %d, %Y") %></span>
</div>
<% end %>
<% unless access_token.scopes.empty? %>
<% if scopes.present? %>
<div>
<span class="detail-label"><%= t "authorized_applications.scopes" %></span>
<span>
<% scope_descriptions = access_token.scopes.map { |scope|
Program::AVAILABLE_SCOPES.find { |s| s[:name] == scope.to_s }&.dig(:description) || scope.to_s
} %>
<%= scope_descriptions.join(", ") %>
<%= scopes.map { |scope|
Program::AVAILABLE_SCOPES.find { |x| x[:name] == scope.to_s }&.dig(:description) || scope.to_s
}.join(", ") %>
</span>
</div>
<% end %>
</div>

<button class="danger small-btn"
hx-delete="<%= authorized_application_path(access_token) %>"
hx-delete="<%= authorized_application_path(tok) %>"
hx-target="#authorized-apps-container"
hx-confirm="<%= t "authorized_applications.revoke_confirmation", app: access_token.application.name %>">
hx-confirm="<%= t "authorized_applications.revoke_confirmation", app: app.name %>">
<%= t "authorized_applications.revoke" %>
</button>
</div>
4 changes: 2 additions & 2 deletions app/views/authorized_applications/index.html.erb
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
<div id="authorized-apps-container" hx-swap-oob="true">
<% if @access_tokens.empty? %>
<% if @authorized_apps.blank? %>
<p class="blank-slate-text"><%= t "authorized_applications.none" %></p>
<% else %>
<div class="sessions-list">
<%= render partial: "authorized_application", collection: @access_tokens, as: :access_token %>
<%= render partial: "authorized_application", collection: @authorized_apps, as: :e %>
</div>
<% end %>
</div>
68 changes: 68 additions & 0 deletions spec/requests/authorized_applications_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
require "rails_helper"

RSpec.describe "AuthorizedApplications", type: :request do
let(:identity) { create(:identity) }
let(:program) { create(:program, :with_all_scopes) }

def tok(scopes:, app: program)
create(:oauth_token, resource_owner: identity, application: app, scopes:)
end

before do
allow_any_instance_of(ApplicationController).to receive(:current_identity).and_return(identity)
end

describe "GET /authorized_applications" do
it "lists one entry per app even with multiple tokens" do
tok(scopes: "basic_info")
tok(scopes: "address phone")

get authorized_applications_path
expect(response).to have_http_status(:ok)
expect(response.body.scan(program.name).size).to eq(1)
end
end

describe "DELETE /authorized_applications/:id" do
it "revokes all tokens for the app, not only the clicked one" do
a = tok(scopes: "basic_info")
b = tok(scopes: "address")
other = tok(scopes: "email", app: create(:program))

delete authorized_application_path(a)

expect(response).to redirect_to(security_path)
expect(a.reload.revoked_at).to be_present
expect(b.reload.revoked_at).to be_present
expect(other.reload.revoked_at).to be_nil
end

it "revokes access grants for the app" do
t = tok(scopes: "basic_info")
g = Doorkeeper::AccessGrant.create!(
resource_owner: identity,
application: program,
token: SecureRandom.hex(32),
expires_in: 600,
redirect_uri: program.redirect_uri,
scopes: "basic_info"
)

delete authorized_application_path(t)
expect(g.reload.revoked_at).to be_present
end

it "rejects /api/v1/me for every former token of that app" do
a = tok(scopes: "basic_info address")
b = tok(scopes: "phone")

delete authorized_application_path(a)

get "/api/v1/me", headers: { "Authorization" => "Bearer #{a.token}" }
expect(response).to have_http_status(:unauthorized)

get "/api/v1/me", headers: { "Authorization" => "Bearer #{b.token}" }
expect(response).to have_http_status(:unauthorized)
end
end
end