activeadmin-oidc 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +272 -0
- data/app/controllers/active_admin/oidc/devise/omniauth_callbacks_controller.rb +79 -0
- data/app/views/active_admin/devise/sessions/new.html.erb +7 -0
- data/lib/activeadmin/oidc/configuration.rb +62 -0
- data/lib/activeadmin/oidc/engine.rb +106 -0
- data/lib/activeadmin/oidc/test_helpers.rb +103 -0
- data/lib/activeadmin/oidc/user_provisioner.rb +142 -0
- data/lib/activeadmin/oidc/version.rb +7 -0
- data/lib/activeadmin-oidc.rb +72 -0
- data/lib/generators/active_admin/oidc/install/install_generator.rb +148 -0
- data/lib/generators/active_admin/oidc/install/templates/initializer.rb.tt +48 -0
- data/lib/generators/active_admin/oidc/install/templates/migration.rb.tt +11 -0
- data/lib/generators/active_admin/oidc/install/templates/sessions_new.html.erb +9 -0
- metadata +287 -0
|
@@ -0,0 +1,142 @@
|
|
|
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 = invoke_on_login(admin_user)
|
|
41
|
+
raise ProvisioningError, denial_message unless allowed
|
|
42
|
+
|
|
43
|
+
save!(admin_user)
|
|
44
|
+
admin_user
|
|
45
|
+
rescue RetryProvisioning
|
|
46
|
+
# Concurrent JIT provisioning: another thread inserted first.
|
|
47
|
+
# Re-run once — find_or_adopt_or_build will now find the record.
|
|
48
|
+
retry
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def model
|
|
54
|
+
@model ||= resolve_admin_user_class
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def resolve_admin_user_class
|
|
58
|
+
@config.admin_user_class.is_a?(Class) ? @config.admin_user_class : @config.admin_user_class.constantize
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def validate_claims!
|
|
62
|
+
if @claims["sub"].blank?
|
|
63
|
+
raise ProvisioningError, "OIDC id_token is missing a sub claim"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
claim_key = @config.identity_claim.to_s
|
|
67
|
+
if @claims[claim_key].blank?
|
|
68
|
+
raise ProvisioningError,
|
|
69
|
+
"OIDC id_token is missing identity claim #{claim_key.inspect}"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def find_or_adopt_or_build
|
|
74
|
+
uid = @claims["sub"].to_s
|
|
75
|
+
existing = model.find_by(provider: @provider, uid: uid)
|
|
76
|
+
return existing if existing
|
|
77
|
+
|
|
78
|
+
identity_value = @claims[@config.identity_claim.to_s]
|
|
79
|
+
identity_match = model.find_by(@config.identity_attribute => identity_value)
|
|
80
|
+
|
|
81
|
+
if identity_match
|
|
82
|
+
if identity_match.provider.present? || identity_match.uid.present?
|
|
83
|
+
raise ProvisioningError,
|
|
84
|
+
"Identity #{identity_value.inspect} is already linked to a different account (takeover guard)"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
identity_match.provider = @provider
|
|
88
|
+
identity_match.uid = uid
|
|
89
|
+
return identity_match
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
model.new(provider: @provider, uid: uid)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def assign_base_attributes(admin_user)
|
|
96
|
+
identity_value = @claims[@config.identity_claim.to_s]
|
|
97
|
+
admin_user.public_send("#{@config.identity_attribute}=", identity_value)
|
|
98
|
+
admin_user.oidc_raw_info = sanitized_raw_info if admin_user.respond_to?(:oidc_raw_info=)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def sanitized_raw_info
|
|
102
|
+
@claims.reject { |k, _v| BLOCKED_RAW_INFO_KEYS.include?(k) }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def save!(admin_user)
|
|
106
|
+
admin_user.save!
|
|
107
|
+
rescue ActiveRecord::RecordNotUnique
|
|
108
|
+
raise ProvisioningError, denial_message if @retried
|
|
109
|
+
|
|
110
|
+
# Concurrent JIT provisioning race: the other thread won the
|
|
111
|
+
# insert. Re-run call once to find the now-persisted record.
|
|
112
|
+
@retried = true
|
|
113
|
+
raise RetryProvisioning
|
|
114
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
115
|
+
raise ProvisioningError, e.message
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# The on_login hook is host-app code. If it raises a non-gem
|
|
119
|
+
# exception, we do NOT want the callback action to blow up with
|
|
120
|
+
# a 500 — the cleanest UX is the same generic denial flash the
|
|
121
|
+
# "hook returned false" path produces. We still log the original
|
|
122
|
+
# exception class + message at error level so ops can debug.
|
|
123
|
+
# Gem-internal exceptions (ActiveAdmin::Oidc::Error subclasses)
|
|
124
|
+
# are re-raised untouched so nested provisioning errors surface
|
|
125
|
+
# with their original messages.
|
|
126
|
+
def invoke_on_login(admin_user)
|
|
127
|
+
@config.on_login.call(admin_user, @claims)
|
|
128
|
+
rescue ActiveAdmin::Oidc::Error
|
|
129
|
+
raise
|
|
130
|
+
rescue StandardError => e
|
|
131
|
+
ActiveAdmin::Oidc.logger.error(
|
|
132
|
+
"[activeadmin-oidc] on_login hook raised #{e.class}: #{e.message}"
|
|
133
|
+
)
|
|
134
|
+
raise ProvisioningError, denial_message
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def denial_message
|
|
138
|
+
@config.access_denied_message
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "logger"
|
|
4
|
+
require "active_support/core_ext/object/blank"
|
|
5
|
+
require "activeadmin/oidc/version"
|
|
6
|
+
|
|
7
|
+
# `omniauth-rails_csrf_protection` registers a Railtie that replaces
|
|
8
|
+
# OmniAuth 2.x's Rack-level authenticity check with Rails' own forgery
|
|
9
|
+
# protection. It's a runtime dependency of this gem, but `Bundler.require`
|
|
10
|
+
# in host apps only auto-requires top-level Gemfile entries — not
|
|
11
|
+
# transitive deps pulled in via our gemspec. We `require` it here so
|
|
12
|
+
# that the railtie gets loaded whenever a host does
|
|
13
|
+
# `gem "activeadmin-oidc"`, and CSRF-protected OmniAuth POSTs work
|
|
14
|
+
# out of the box with `button_to`-style login buttons.
|
|
15
|
+
#
|
|
16
|
+
# `omniauth/rails_csrf_protection/railtie` references `Rails::Railtie`
|
|
17
|
+
# at file load, so pull in the minimal Railtie base class first — this
|
|
18
|
+
# keeps the require safe even in spec_helper contexts where the full
|
|
19
|
+
# Rails stack hasn't been initialized yet.
|
|
20
|
+
require "rails/railtie"
|
|
21
|
+
require "omniauth/rails_csrf_protection"
|
|
22
|
+
|
|
23
|
+
module ActiveAdmin
|
|
24
|
+
module Oidc
|
|
25
|
+
class Error < StandardError; end
|
|
26
|
+
class ConfigurationError < Error; end
|
|
27
|
+
class ProvisioningError < Error; end
|
|
28
|
+
class RetryProvisioning < Error; end
|
|
29
|
+
|
|
30
|
+
class << self
|
|
31
|
+
def config
|
|
32
|
+
@config ||= Configuration.new
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def configure
|
|
36
|
+
yield config
|
|
37
|
+
config
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Logger the gem uses for internal diagnostics (on_login hook
|
|
41
|
+
# failures, omniauth failures, etc). Defaults to Rails.logger when
|
|
42
|
+
# Rails is booted, falls back to a null logger otherwise so that
|
|
43
|
+
# library code is safe to call in non-Rails contexts (unit specs,
|
|
44
|
+
# scripts). Override by assigning directly — useful in tests.
|
|
45
|
+
def logger
|
|
46
|
+
@logger || default_logger
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
attr_writer :logger
|
|
50
|
+
|
|
51
|
+
def reset!
|
|
52
|
+
@config = Configuration.new
|
|
53
|
+
@logger = nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def default_logger
|
|
59
|
+
if defined?(::Rails) && ::Rails.respond_to?(:logger) && ::Rails.logger
|
|
60
|
+
::Rails.logger
|
|
61
|
+
else
|
|
62
|
+
@null_logger ||= ::Logger.new(IO::NULL)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
require "activeadmin/oidc/configuration"
|
|
70
|
+
require "activeadmin/oidc/user_provisioner"
|
|
71
|
+
require "rails/engine"
|
|
72
|
+
require "activeadmin/oidc/engine"
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators/base"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module ActiveAdmin
|
|
7
|
+
module Oidc
|
|
8
|
+
module Generators
|
|
9
|
+
# `bin/rails generate active_admin:oidc:install`
|
|
10
|
+
#
|
|
11
|
+
# Produces a working starting point for the host app:
|
|
12
|
+
#
|
|
13
|
+
# * config/initializers/activeadmin_oidc.rb (commented template)
|
|
14
|
+
# * db/migrate/<ts>_add_oidc_to_admin_users.rb
|
|
15
|
+
# * app/views/active_admin/devise/sessions/new.html.erb (override)
|
|
16
|
+
#
|
|
17
|
+
# Idempotent: running twice is a no-op for all three files (the
|
|
18
|
+
# migration is skipped if an *_add_oidc_to_admin_users.rb file
|
|
19
|
+
# already exists on disk, even with a different timestamp).
|
|
20
|
+
#
|
|
21
|
+
# Refuses to run if the host app is missing an `AdminUser` model
|
|
22
|
+
# or the `devise` / `activeadmin` gems — those are the sharp
|
|
23
|
+
# corners it can't work around, and silently succeeding would
|
|
24
|
+
# produce broken configuration.
|
|
25
|
+
class InstallGenerator < ::Rails::Generators::Base
|
|
26
|
+
include ::Rails::Generators::Migration
|
|
27
|
+
|
|
28
|
+
source_root File.expand_path("templates", __dir__)
|
|
29
|
+
|
|
30
|
+
desc "Installs activeadmin-oidc into the host Rails app."
|
|
31
|
+
|
|
32
|
+
def self.next_migration_number(dirname)
|
|
33
|
+
::ActiveRecord::Generators::Base.next_migration_number(dirname)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def verify_host_app!
|
|
37
|
+
unless File.exist?(File.join(destination_root, "app/models/admin_user.rb"))
|
|
38
|
+
raise ::Thor::Error,
|
|
39
|
+
"activeadmin-oidc: could not find app/models/admin_user.rb — " \
|
|
40
|
+
"install activeadmin + devise first and make sure the AdminUser " \
|
|
41
|
+
"model exists."
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
gemfile_lock = File.join(destination_root, "Gemfile.lock")
|
|
45
|
+
lock_contents = File.exist?(gemfile_lock) ? File.read(gemfile_lock) : ""
|
|
46
|
+
|
|
47
|
+
unless lock_contents.match?(/^\s+devise\b/)
|
|
48
|
+
raise ::Thor::Error,
|
|
49
|
+
"activeadmin-oidc: devise not found in Gemfile.lock. " \
|
|
50
|
+
"Add `gem \"devise\"` and run `bundle install` first."
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
unless lock_contents.match?(/^\s+activeadmin\b/)
|
|
54
|
+
raise ::Thor::Error,
|
|
55
|
+
"activeadmin-oidc: activeadmin not found in Gemfile.lock. " \
|
|
56
|
+
"Add `gem \"activeadmin\"` and run `bundle install` first."
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def create_initializer
|
|
61
|
+
template "initializer.rb.tt", "config/initializers/activeadmin_oidc.rb"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def create_migration_file
|
|
65
|
+
# Idempotency: if a previous run already produced this
|
|
66
|
+
# migration (any timestamp), do nothing. We never want to
|
|
67
|
+
# stack up duplicate add_column migrations.
|
|
68
|
+
existing = Dir[File.join(destination_root, "db/migrate/*_add_oidc_to_admin_users.rb")]
|
|
69
|
+
return if existing.any?
|
|
70
|
+
|
|
71
|
+
@raw_info_type = raw_info_column_type
|
|
72
|
+
migration_template "migration.rb.tt",
|
|
73
|
+
"db/migrate/add_oidc_to_admin_users.rb"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def create_view_override
|
|
77
|
+
copy_file "sessions_new.html.erb",
|
|
78
|
+
"app/views/active_admin/devise/sessions/new.html.erb"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Non-blocking: the generator completed successfully, but the
|
|
82
|
+
# host AdminUser may be missing the Devise modules the gem needs.
|
|
83
|
+
# We warn rather than hard-fail because a mid-refactor user may
|
|
84
|
+
# know exactly what they're doing.
|
|
85
|
+
def warn_missing_admin_user_wiring
|
|
86
|
+
admin_user_path = File.join(destination_root, "app/models/admin_user.rb")
|
|
87
|
+
return unless File.exist?(admin_user_path)
|
|
88
|
+
|
|
89
|
+
contents = File.read(admin_user_path)
|
|
90
|
+
|
|
91
|
+
unless contents.match?(/:omniauthable/)
|
|
92
|
+
say_status :warning,
|
|
93
|
+
"app/models/admin_user.rb does not include :omniauthable — " \
|
|
94
|
+
"add `:omniauthable, omniauth_providers: [:oidc]` to your `devise` call.",
|
|
95
|
+
:yellow
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
unless contents.match?(/omniauth_providers\s*:\s*\[\s*:oidc/)
|
|
99
|
+
say_status :warning,
|
|
100
|
+
"app/models/admin_user.rb does not declare `omniauth_providers: [:oidc]` — " \
|
|
101
|
+
"the gem's callback controller will not be reachable without it.",
|
|
102
|
+
:yellow
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Print a short checklist of the host-app wiring the generator
|
|
107
|
+
# can't do itself. These are the footguns that cost us an hour
|
|
108
|
+
# the first time we wired a fresh demo app: commented-out
|
|
109
|
+
# `authentication_method` / `current_user_method` in the
|
|
110
|
+
# ActiveAdmin initializer, and forgetting to migrate.
|
|
111
|
+
def print_next_steps
|
|
112
|
+
say ""
|
|
113
|
+
say "=" * 60, :green
|
|
114
|
+
say "activeadmin-oidc: next steps", :green
|
|
115
|
+
say "=" * 60, :green
|
|
116
|
+
say <<~STEPS
|
|
117
|
+
1. In config/initializers/active_admin.rb, uncomment:
|
|
118
|
+
config.authentication_method = :authenticate_admin_user!
|
|
119
|
+
config.current_user_method = :current_admin_user
|
|
120
|
+
Without these, /admin is public and the utility nav
|
|
121
|
+
(including the logout button) renders empty.
|
|
122
|
+
|
|
123
|
+
2. In app/models/admin_user.rb, make sure the devise call
|
|
124
|
+
includes:
|
|
125
|
+
:omniauthable, omniauth_providers: [:oidc]
|
|
126
|
+
|
|
127
|
+
3. Fill in the generated config/initializers/activeadmin_oidc.rb
|
|
128
|
+
with your issuer and client_id (plus client_secret if you
|
|
129
|
+
are NOT using PKCE public-client mode).
|
|
130
|
+
|
|
131
|
+
4. Apply the generated migration:
|
|
132
|
+
bin/rails db:migrate
|
|
133
|
+
STEPS
|
|
134
|
+
say "=" * 60, :green
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
private
|
|
138
|
+
|
|
139
|
+
# Postgres gets `jsonb` (fast, indexable), everything else
|
|
140
|
+
# falls back to `:text` so sqlite/mysql hosts aren't left out.
|
|
141
|
+
def raw_info_column_type
|
|
142
|
+
adapter = (ActiveRecord::Base.connection_db_config.adapter rescue "sqlite3").to_s
|
|
143
|
+
adapter.start_with?("postgres") ? ":jsonb" : ":text"
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# activeadmin-oidc initializer — generated by
|
|
4
|
+
# bin/rails generate active_admin:oidc:install
|
|
5
|
+
#
|
|
6
|
+
# Uncomment and fill in the values for your identity provider.
|
|
7
|
+
|
|
8
|
+
ActiveAdmin::Oidc.configure do |c|
|
|
9
|
+
# --- Provider ---------------------------------------------------------
|
|
10
|
+
# c.issuer = ENV.fetch("OIDC_ISSUER")
|
|
11
|
+
# c.client_id = ENV.fetch("OIDC_CLIENT_ID")
|
|
12
|
+
# c.client_secret = ENV.fetch("OIDC_CLIENT_SECRET", nil) # blank = PKCE public client
|
|
13
|
+
|
|
14
|
+
# --- Identity lookup --------------------------------------------------
|
|
15
|
+
# Which AdminUser column to match against when a (provider, uid) lookup
|
|
16
|
+
# misses and we need to migrate an existing row to SSO, and which claim
|
|
17
|
+
# on the id_token/userinfo to read it from.
|
|
18
|
+
# c.identity_attribute = :email
|
|
19
|
+
# c.identity_claim = :email
|
|
20
|
+
|
|
21
|
+
# --- Login button label ----------------------------------------------
|
|
22
|
+
# c.login_button_label = "Sign in with Corporate SSO"
|
|
23
|
+
|
|
24
|
+
# --- on_login hook ---------------------------------------------------
|
|
25
|
+
# Called with (admin_user, claims) after identity lookup and before
|
|
26
|
+
# save. Mutate admin_user in place, return truthy to allow sign-in,
|
|
27
|
+
# return falsy to deny. This is the ONLY place authorization lives —
|
|
28
|
+
# the gem does not ship a role model.
|
|
29
|
+
#
|
|
30
|
+
# Example A — Zitadel with nested project roles claim:
|
|
31
|
+
#
|
|
32
|
+
# c.on_login = ->(admin_user, claims) {
|
|
33
|
+
# roles = claims["urn:zitadel:iam:org:project:roles"]&.keys || []
|
|
34
|
+
# return false if roles.empty?
|
|
35
|
+
# admin_user.roles = roles
|
|
36
|
+
# true
|
|
37
|
+
# }
|
|
38
|
+
#
|
|
39
|
+
# Example B — department-style authorization:
|
|
40
|
+
#
|
|
41
|
+
# KNOWN_DEPARTMENTS = %w[ops eng support].freeze
|
|
42
|
+
# c.on_login = ->(admin_user, claims) {
|
|
43
|
+
# dept = claims["department"]
|
|
44
|
+
# return false unless KNOWN_DEPARTMENTS.include?(dept)
|
|
45
|
+
# admin_user.department = dept
|
|
46
|
+
# true
|
|
47
|
+
# }
|
|
48
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class AddOidcToAdminUsers < ActiveRecord::Migration[7.0]
|
|
4
|
+
def change
|
|
5
|
+
add_column :admin_users, :provider, :string
|
|
6
|
+
add_column :admin_users, :uid, :string
|
|
7
|
+
add_column :admin_users, :oidc_raw_info, <%= @raw_info_type %>
|
|
8
|
+
|
|
9
|
+
add_index :admin_users, [:provider, :uid], unique: true
|
|
10
|
+
end
|
|
11
|
+
end
|