Skip to content

Commit 063cce8

Browse files
committed
TDD step 11: Security hardening pass
Cross-cutting security assertions landing three pieces: 1. Engine initializer merges :code, :id_token, :access_token, and :refresh_token into Rails.application.config.filter_parameters so a crash mid-callback can never spill OAuth codes or bearer tokens into production logs. The host app's own filters are preserved. 2. Explicit spec proving UserProvisioner strips access_token, refresh_token, and id_token from oidc_raw_info even if the host's on_login leaves them in the claims hash — defense in depth for a stolen DB dump. 3. Explicit spec proving the user-facing denial flash is the same generic message whether on_login returned false or the identity claim was missing, so an attacker can't enumerate valid users vs valid authorization rules. All other checklist items (state, nonce, iss/aud, exp, alg, jwks) are delegated to omniauth_openid_connect and tested via their failure-path behavior in the existing callback specs.
1 parent 088d3eb commit 063cce8

2 files changed

Lines changed: 129 additions & 0 deletions

File tree

lib/activeadmin/oidc/engine.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,15 @@ def controllers
5252
::ActiveAdmin::Devise::SessionsController.prepend_view_path(view_path)
5353
end
5454
end
55+
56+
# Prevent OAuth codes and tokens from ever landing in Rails logs.
57+
# If the host app crashes mid-callback Rails will dump the full
58+
# params hash into the log; we merge the known-sensitive keys
59+
# into `filter_parameters` so they come out as [FILTERED]. The
60+
# host app's own filter_parameters are preserved — we only add.
61+
initializer "activeadmin_oidc.filter_parameters" do |app|
62+
app.config.filter_parameters |= %i[code id_token access_token refresh_token]
63+
end
5564
end
5665
end
5766
end

spec/security_spec.rb

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
# frozen_string_literal: true
2+
3+
require "rails_helper"
4+
5+
# Cross-cutting security checklist from docs/TEST_PLAN.md §8. Most
6+
# protocol-level checks (state, nonce, alg, iss/aud, exp, jwks) are
7+
# delegated to omniauth_openid_connect and only their *failure-path*
8+
# behavior is verified here — we care that a bad token ends in the
9+
# gem's denial flow, not that we re-implement JWT verification.
10+
RSpec.describe "Security posture", type: :request do
11+
describe "log filter_parameters" do
12+
# A gem that ships an OAuth flow and doesn't filter the tokens
13+
# out of the Rails log is a liability — a crash mid-callback will
14+
# dump the whole params hash (including `code`, `id_token`,
15+
# `access_token`, `refresh_token`) straight into production logs.
16+
#
17+
# Note: once Rails has served a request, filter_parameters is
18+
# compiled into a single combined regex (e.g.
19+
# "(?-mix:(?i:code)|(?i:id_token)|(?i:access_token)|...)"
20+
# ), so we stringify the collection and look for each key in it
21+
# rather than asserting on discrete symbol entries.
22+
it "includes the OIDC token/code keys so they never end up in Rails logs" do
23+
filters_str = Rails.application.config.filter_parameters.map(&:to_s).join(" ")
24+
25+
%w[code id_token access_token refresh_token].each do |key|
26+
expect(filters_str).to include(key),
27+
"expected filter_parameters to filter #{key.inspect}, got: #{filters_str}"
28+
end
29+
end
30+
end
31+
32+
describe "oidc_raw_info persistence" do
33+
include ActiveAdmin::Oidc
34+
let(:config) { ActiveAdmin::Oidc::Configuration.new }
35+
36+
before do
37+
config.issuer = "https://idp.example.com"
38+
config.client_id = "client-abc"
39+
config.on_login = ->(*) { true }
40+
AdminUser.delete_all
41+
end
42+
43+
# Defense in depth: even if the host app's `on_login` somehow
44+
# pulls `access_token` into claims, UserProvisioner strips it
45+
# before writing oidc_raw_info. A stolen DB dump must never yield
46+
# usable bearer tokens.
47+
it "never persists access_token or refresh_token into oidc_raw_info" do
48+
provisioner = ActiveAdmin::Oidc::UserProvisioner.new(
49+
config,
50+
claims: {
51+
"sub" => "sub-999",
52+
"email" => "safe@example.com",
53+
"access_token" => "secret-bearer",
54+
"refresh_token" => "secret-refresh",
55+
"id_token" => "should-not-persist"
56+
},
57+
provider: "oidc"
58+
)
59+
60+
admin_user = provisioner.call
61+
62+
expect(admin_user.oidc_raw_info).to include("sub" => "sub-999", "email" => "safe@example.com")
63+
expect(admin_user.oidc_raw_info).not_to have_key("access_token")
64+
expect(admin_user.oidc_raw_info).not_to have_key("refresh_token")
65+
expect(admin_user.oidc_raw_info).not_to have_key("id_token")
66+
end
67+
end
68+
69+
describe "generic denial message" do
70+
# Enumeration defense: the user-facing flash must NOT reveal which
71+
# specific failure happened (unknown user vs. missing claim vs.
72+
# on_login falsy). Everything funnels into
73+
# `config.access_denied_message`.
74+
let(:generic) { "Your account has no permission to access this admin panel." }
75+
76+
before do
77+
ActiveAdmin::Oidc.configure do |c|
78+
c.issuer = "https://idp.example.com"
79+
c.client_id = "client-abc"
80+
c.access_denied_message = generic
81+
end
82+
AdminUser.delete_all
83+
OmniAuth.config.mock_auth[:oidc] = nil
84+
end
85+
86+
after do
87+
OmniAuth.config.mock_auth[:oidc] = nil
88+
ActiveAdmin::Oidc.config.reset!
89+
end
90+
91+
it "renders the same flash whether on_login returns false or a claim is missing" do
92+
# Case 1: on_login returns false
93+
ActiveAdmin::Oidc.config.on_login = ->(*) { false }
94+
OmniAuth.config.mock_auth[:oidc] = OmniAuth::AuthHash.new(
95+
provider: "oidc",
96+
uid: "sub-1",
97+
info: { email: "u@example.com" },
98+
extra: { raw_info: { "sub" => "sub-1", "email" => "u@example.com" } }
99+
)
100+
post "/admin/auth/oidc"
101+
follow_redirect!
102+
denial_flash_1 = flash[:alert]
103+
104+
# Case 2: missing identity claim
105+
ActiveAdmin::Oidc.config.on_login = ->(*) { true }
106+
OmniAuth.config.mock_auth[:oidc] = OmniAuth::AuthHash.new(
107+
provider: "oidc",
108+
uid: "sub-2",
109+
info: {},
110+
extra: { raw_info: { "sub" => "sub-2" } }
111+
)
112+
post "/admin/auth/oidc"
113+
follow_redirect!
114+
denial_flash_2 = flash[:alert]
115+
116+
expect(denial_flash_1).to eq(generic)
117+
expect(denial_flash_2).to eq(generic)
118+
end
119+
end
120+
end

0 commit comments

Comments
 (0)