Skip to content

Commit 056d016

Browse files
committed
TDD steps 1-5: Configuration, Discovery, Zitadel preset, UserProvisioner
Implements the unit-test layer of the test plan, in red-green-refactor order. 72 examples, 0 failures. - Configuration: defaults, validate!, pkce auto-on when client_secret blank, preset loader. No callback_path config (hardcoded on engine). - Discovery: own HTTP fetch with DiscoveryError on 5xx / malformed JSON / timeout / issuer-mismatch. Memoized, supports per-endpoint override. - Presets::Zitadel: two-line preset (scope default + pkce-if-no-secret). No role parsing here; that moves to the host on_login hook. - UserProvisioner: lookup by (provider, uid), then identity-attribute migration, then new record, then on_login hook, then save. Denial raises ProvisioningError with config.access_denied_message; existing records are never mutated on denial. oidc_raw_info strips tokens. Also rewrites docs/TEST_PLAN.md around the "no gem-owned authorization model" design: the host app owns all role / permission / department columns and assigns them inside a single on_login(admin_user, claims) lambda. Added section 12 "Design rationale" explaining why.
1 parent c1d0caa commit 056d016

14 files changed

Lines changed: 1255 additions & 105 deletions

docs/TEST_PLAN.md

Lines changed: 204 additions & 105 deletions
Large diffs are not rendered by default.

lib/activeadmin-oidc.rb

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,25 @@ class Error < StandardError; end
88
class ConfigurationError < Error; end
99
class DiscoveryError < Error; end
1010
class ProvisioningError < Error; end
11+
12+
class << self
13+
def config
14+
@config ||= Configuration.new
15+
end
16+
17+
def configure
18+
yield config
19+
config
20+
end
21+
22+
def reset!
23+
@config = Configuration.new
24+
end
25+
end
1126
end
1227
end
28+
29+
require "activeadmin/oidc/configuration"
30+
require "activeadmin/oidc/discovery"
31+
require "activeadmin/oidc/presets/zitadel"
32+
require "activeadmin/oidc/user_provisioner"
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# frozen_string_literal: true
2+
3+
module ActiveAdmin
4+
module Oidc
5+
class Configuration
6+
DEFAULT_SCOPE = "openid email profile"
7+
DEFAULT_CLOCK_SKEW = 30
8+
DEFAULT_TIMEOUT = 5
9+
DEFAULT_IDENTITY_ATTRIBUTE = :email
10+
DEFAULT_IDENTITY_CLAIM = :email
11+
DEFAULT_LOGIN_BUTTON_LABEL = "Sign in with SSO"
12+
DEFAULT_ACCESS_DENIED_MESSAGE =
13+
"Your account has no permission to access this admin panel."
14+
15+
attr_accessor :issuer, :client_id, :client_secret, :scope,
16+
:login_button_label, :clock_skew, :timeout,
17+
:identity_attribute, :identity_claim,
18+
:access_denied_message, :on_login
19+
20+
def initialize
21+
reset!
22+
end
23+
24+
def reset!
25+
@issuer = nil
26+
@client_id = nil
27+
@client_secret = nil
28+
@scope = DEFAULT_SCOPE
29+
@login_button_label = DEFAULT_LOGIN_BUTTON_LABEL
30+
@clock_skew = DEFAULT_CLOCK_SKEW
31+
@timeout = DEFAULT_TIMEOUT
32+
@identity_attribute = DEFAULT_IDENTITY_ATTRIBUTE
33+
@identity_claim = DEFAULT_IDENTITY_CLAIM
34+
@access_denied_message = DEFAULT_ACCESS_DENIED_MESSAGE
35+
@on_login = nil
36+
@pkce_override = nil
37+
self
38+
end
39+
40+
def pkce
41+
return @pkce_override unless @pkce_override.nil?
42+
43+
client_secret.nil? || client_secret.to_s.empty?
44+
end
45+
46+
def pkce=(value)
47+
@pkce_override = value
48+
end
49+
50+
def preset(name)
51+
case name
52+
when :zitadel
53+
require "activeadmin/oidc/presets/zitadel"
54+
Presets::Zitadel.apply(self)
55+
else
56+
raise ConfigurationError, "Unknown preset: #{name.inspect}"
57+
end
58+
end
59+
60+
def validate!
61+
raise ConfigurationError, "issuer is required" if blank?(issuer)
62+
raise ConfigurationError, "client_id is required" if blank?(client_id)
63+
raise ConfigurationError, "on_login is required" if on_login.nil?
64+
unless on_login.respond_to?(:call)
65+
raise ConfigurationError, "on_login must be callable (respond to #call)"
66+
end
67+
68+
true
69+
end
70+
71+
private
72+
73+
def blank?(value)
74+
value.nil? || value.to_s.strip.empty?
75+
end
76+
end
77+
end
78+
end

lib/activeadmin/oidc/discovery.rb

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
# frozen_string_literal: true
2+
3+
require "net/http"
4+
require "json"
5+
require "uri"
6+
7+
module ActiveAdmin
8+
module Oidc
9+
# Thin wrapper around the OIDC discovery document
10+
# (`.well-known/openid-configuration`). Does its own HTTP fetch so the
11+
# gem can expose a normalized interface and error type independent of
12+
# `omniauth_openid_connect`'s internals.
13+
class Discovery
14+
DISCOVERY_PATH = "/.well-known/openid-configuration"
15+
16+
def initialize(config)
17+
@config = config
18+
end
19+
20+
def document
21+
@document ||= fetch_and_validate!
22+
end
23+
24+
def authorization_endpoint(override: nil)
25+
override || document.fetch("authorization_endpoint")
26+
end
27+
28+
def token_endpoint(override: nil)
29+
override || document.fetch("token_endpoint")
30+
end
31+
32+
def userinfo_endpoint(override: nil)
33+
override || document.fetch("userinfo_endpoint")
34+
end
35+
36+
def jwks_uri(override: nil)
37+
override || document.fetch("jwks_uri")
38+
end
39+
40+
def end_session_endpoint(override: nil)
41+
override || document["end_session_endpoint"]
42+
end
43+
44+
def issuer
45+
document.fetch("issuer")
46+
end
47+
48+
private
49+
50+
attr_reader :config
51+
52+
def fetch_and_validate!
53+
body = fetch_body
54+
doc = parse_json(body)
55+
verify_issuer!(doc)
56+
doc
57+
end
58+
59+
def fetch_body
60+
uri = URI.join(ensure_trailing_slash(config.issuer), DISCOVERY_PATH.sub(%r{^/}, ""))
61+
http = Net::HTTP.new(uri.host, uri.port)
62+
http.use_ssl = uri.scheme == "https"
63+
http.open_timeout = config.timeout
64+
http.read_timeout = config.timeout
65+
66+
response = http.request(Net::HTTP::Get.new(uri.request_uri))
67+
unless response.is_a?(Net::HTTPSuccess)
68+
raise DiscoveryError, "Discovery failed with HTTP #{response.code}"
69+
end
70+
71+
response.body
72+
rescue Net::OpenTimeout, Net::ReadTimeout, Timeout::Error => e
73+
raise DiscoveryError, "Discovery timed out: #{e.message}"
74+
rescue SocketError, Errno::ECONNREFUSED => e
75+
raise DiscoveryError, "Discovery connection failed: #{e.message}"
76+
end
77+
78+
def parse_json(body)
79+
JSON.parse(body)
80+
rescue JSON::ParserError => e
81+
raise DiscoveryError, "Discovery returned invalid JSON: #{e.message}"
82+
end
83+
84+
def verify_issuer!(doc)
85+
discovered = doc["issuer"].to_s
86+
configured = config.issuer.to_s
87+
return if discovered == configured
88+
89+
raise DiscoveryError,
90+
"Discovery issuer mismatch: document said #{discovered.inspect}, " \
91+
"config expected #{configured.inspect}"
92+
end
93+
94+
def ensure_trailing_slash(url)
95+
url.to_s.end_with?("/") ? url : "#{url}/"
96+
end
97+
end
98+
end
99+
end
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# frozen_string_literal: true
2+
3+
module ActiveAdmin
4+
module Oidc
5+
module Presets
6+
module Zitadel
7+
DEFAULT_SCOPE = "openid email profile"
8+
9+
def self.apply(config)
10+
config.scope = DEFAULT_SCOPE if config.scope == Configuration::DEFAULT_SCOPE
11+
config.pkce = true if blank?(config.client_secret) && config.instance_variable_get(:@pkce_override).nil?
12+
config
13+
end
14+
15+
def self.blank?(value)
16+
value.nil? || value.to_s.strip.empty?
17+
end
18+
end
19+
end
20+
end
21+
end
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
# frozen_string_literal: true
2+
3+
module ActiveAdmin
4+
module Oidc
5+
# Finds-or-creates an AdminUser for an OIDC callback. Runs the host's
6+
# `on_login` hook (which owns all authorization decisions), then saves.
7+
#
8+
# provisioner = UserProvisioner.new(config, claims: merged_claims, provider: "oidc")
9+
# admin_user = provisioner.call # raises ProvisioningError on denial
10+
#
11+
# Strategy:
12+
#
13+
# 1. Look up by (provider, uid). If found → update.
14+
# 2. Otherwise look up by the configured identity_attribute. If that row
15+
# is already locked to a different (provider, uid) → refuse
16+
# (account-takeover guard). Otherwise adopt it.
17+
# 3. Otherwise build a new record.
18+
# 4. Assign the identity attribute and oidc_raw_info.
19+
# 5. Call config.on_login(admin_user, claims). Falsy → deny. Truthy →
20+
# save and return.
21+
#
22+
# The claims hash is passed through untouched except that `access_token`
23+
# and `refresh_token` (if present) are never persisted.
24+
class UserProvisioner
25+
# Claim keys that must never land in oidc_raw_info.
26+
BLOCKED_RAW_INFO_KEYS = %w[access_token refresh_token id_token].freeze
27+
28+
def initialize(config, claims:, provider:)
29+
@config = config
30+
@claims = claims.transform_keys(&:to_s)
31+
@provider = provider
32+
end
33+
34+
def call
35+
validate_claims!
36+
37+
admin_user = find_or_adopt_or_build
38+
assign_base_attributes(admin_user)
39+
40+
allowed = @config.on_login.call(admin_user, @claims)
41+
raise ProvisioningError, denial_message unless allowed
42+
43+
save!(admin_user)
44+
admin_user
45+
end
46+
47+
private
48+
49+
def model
50+
@model ||= admin_user_class
51+
end
52+
53+
def admin_user_class
54+
klass_name = @config.respond_to?(:admin_user_class) && @config.admin_user_class
55+
return klass_name if klass_name.is_a?(Class)
56+
57+
Object.const_get(klass_name || "AdminUser")
58+
end
59+
60+
def validate_claims!
61+
if blank?(@claims["sub"])
62+
raise ProvisioningError, "OIDC id_token is missing a sub claim"
63+
end
64+
65+
claim_key = @config.identity_claim.to_s
66+
if blank?(@claims[claim_key])
67+
raise ProvisioningError,
68+
"OIDC id_token is missing identity claim #{claim_key.inspect}"
69+
end
70+
end
71+
72+
def find_or_adopt_or_build
73+
uid = @claims["sub"].to_s
74+
existing = model.find_by(provider: @provider, uid: uid)
75+
return existing if existing
76+
77+
identity_value = @claims[@config.identity_claim.to_s]
78+
identity_match = model.find_by(@config.identity_attribute => identity_value)
79+
80+
if identity_match
81+
if identity_match.provider.present? || identity_match.uid.present?
82+
raise ProvisioningError,
83+
"Identity #{identity_value.inspect} is already linked to a different account (takeover guard)"
84+
end
85+
86+
identity_match.provider = @provider
87+
identity_match.uid = uid
88+
return identity_match
89+
end
90+
91+
model.new(provider: @provider, uid: uid)
92+
end
93+
94+
def assign_base_attributes(admin_user)
95+
identity_value = @claims[@config.identity_claim.to_s]
96+
admin_user.public_send("#{@config.identity_attribute}=", identity_value)
97+
admin_user.oidc_raw_info = sanitized_raw_info if admin_user.respond_to?(:oidc_raw_info=)
98+
end
99+
100+
def sanitized_raw_info
101+
@claims.reject { |k, _v| BLOCKED_RAW_INFO_KEYS.include?(k) }
102+
end
103+
104+
def save!(admin_user)
105+
admin_user.save!
106+
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique => e
107+
raise ProvisioningError, e.message
108+
end
109+
110+
def denial_message
111+
@config.access_denied_message
112+
end
113+
114+
def blank?(value)
115+
value.nil? || value.to_s.strip.empty?
116+
end
117+
end
118+
end
119+
end

spec/spec_helper.rb

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# frozen_string_literal: true
2+
3+
$LOAD_PATH.unshift File.expand_path("../lib", __dir__)
4+
5+
require "activeadmin-oidc"
6+
7+
Dir[File.expand_path("support/**/*.rb", __dir__)].each { |f| require f }
8+
9+
RSpec.configure do |config|
10+
config.expect_with :rspec do |expectations|
11+
expectations.include_chain_clauses_in_custom_matcher_descriptions = true
12+
end
13+
14+
config.mock_with :rspec do |mocks|
15+
mocks.verify_partial_doubles = true
16+
end
17+
18+
config.shared_context_metadata_behavior = :apply_to_host_groups
19+
config.disable_monkey_patching!
20+
config.warnings = true
21+
config.order = :random
22+
Kernel.srand config.seed
23+
24+
config.before(:each) { ActiveAdmin::Oidc.reset! if ActiveAdmin::Oidc.respond_to?(:reset!) }
25+
end

0 commit comments

Comments
 (0)