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.
@@ -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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAdmin
4
+ module Oidc
5
+ VERSION = "1.0.0"
6
+ end
7
+ 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
@@ -0,0 +1,9 @@
1
+ <div id="login">
2
+ <h2><%%= active_admin_application.site_title(self) %></h2>
3
+
4
+ <%%= button_to ActiveAdmin::Oidc.config.login_button_label,
5
+ "/admin/auth/oidc",
6
+ method: :post,
7
+ class: "activeadmin-oidc-login-button",
8
+ data: { turbo: false } %>
9
+ </div>