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
99 changes: 99 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,10 @@ ActiveAdmin::Oidc.configure do |c|
# By default PKCE is enabled iff client_secret is blank. Override:
# c.pkce = true

# --- Stub login (development only) ------------------------------------
# See "Stub login" below. A no-op outside the development environment.
# c.stub_dev_env_login!

# --- Authorization hook (REQUIRED) ------------------------------------
c.on_login = ->(admin_user, claims) {
# ... see "The on_login hook" below
Expand All @@ -137,6 +141,8 @@ end
| `access_denied_message` | generic | Flash shown on any denial |
| `on_login` | — (required) | Authorization hook; see below |

`stub_dev_env_login!` is a method, not an option — see "Stub login" below.

## The `on_login` hook

`on_login` is the **only** place authorization lives. The gem handles authentication (the user proved who they are via the IdP); deciding whether that user is allowed into the admin panel — and what they can see once they are in — is the host application's problem. The gem does not ship a role model.
Expand Down Expand Up @@ -243,8 +249,101 @@ AdminUser.last.oidc_raw_info
* Clicking it POSTs to `/admin/auth/oidc` with a Rails CSRF token. The gem loads `omniauth-rails_csrf_protection` so OmniAuth 2.x delegates its authenticity check to Rails' forgery protection and `button_to` just works.
* After a successful callback the user is signed in and redirected to `/admin` (not the host app's `/`, which may not exist).
* **Disabled/locked users are rejected.** Devise's `active_for_authentication?` is checked after provisioning but before sign-in. If your model overrides this method (e.g. to check an `enabled` flag or Devise's `:lockable` module), the guard fires on OIDC sign-in too — the user sees an appropriate flash and is redirected to the login page.
* In development, `stub_dev_env_login!` repoints that same button at a local sign-in that never contacts the IdP — see "Stub login" below.
* Logout goes through Devise's stock session destroy. No RP-initiated single-logout ping to the IdP — override the destroy action in your host app if you need that.

## Stub login (development)

When OIDC is the only way in, local development gets harder than the feature
you were trying to build. Some providers accept a `localhost:3000` redirect
URI — until you need a different port, or a second app that also speaks OIDC on
the same machine. Registering and juggling those redirect URIs is work that has
nothing to do with the change you are making.

Stub login is the escape hatch. Turn it on and the login page renders exactly as
it always does — same button, same label — but the button signs in with locally
fabricated claims instead of redirecting to the IdP. A red warning sits above
it while it is on.

```ruby
ActiveAdmin::Oidc.configure do |c|
c.on_login = ->(admin_user, claims) {
groups = Array(claims["groups"])
return false unless groups.include?(ADMIN_GROUP)

admin_user.super_admin = groups.include?("super-admins")
true
}

# Default claims: { "sub" => "stub-uid", "email" => "stub-dev@example.com" }
# The optional block patches or replaces them.
c.stub_dev_env_login! do |claims|
claims.merge("groups" => [ADMIN_GROUP])
end
end
```

`stub_dev_env_login!` is a **no-op outside the development environment** and
returns `false` there. That is the whole safety story: nothing to flip off
before a deploy, no boot guard to trip, and no route drawn anywhere else.
Leave the call uncommented in the initializer if you want.

The block runs once per sign-in, not once at boot. To switch between distinct
users, vary both `sub` and the configured identity claim; changing only the
email retains `stub-uid` and updates the same `(provider, uid)` row.

### It is not a separate code path

The button POSTs to the gem's own callbacks controller, which hands the claims
to the **same `UserProvisioner` a real callback uses**. That means identity
lookup by `(provider, uid)`, the account-takeover guard, your `on_login` hook,
`oidc_raw_info`, and Devise's `active_for_authentication?` check all still run.
Authorization you develop against the stub is the authorization you get against
the IdP.

Three consequences worth internalising:

* **Your `on_login` hook must accept the stub claims.** If it reads
`claims["groups"]` or Zitadel's nested roles claim, add them in the block —
otherwise the stub is denied, correctly. You will find out the first time you
click the button, and fix it once.
* **Use a made-up email, never your real one.** A stub sign-in writes the fake
`sub` onto that row. Your real sign-in later brings the real `sub`, they do not
match, and the takeover guard locks you out of that account until someone
clears the columns by hand.
* **`provider` is always `"oidc"`,** never a separate `"stub"`/`"test"` value,
so the row a stub sign-in creates is an ordinary OIDC row.

### Limits

* **Development only.** Nothing else enables it.
* **Devise mounted inside an isolated engine is not supported.** The stub URL is
built as `"#{login_path}/stub"`, which does not account for a mount prefix.
The real SSO flow is unaffected.
* **CSRF protection is your app's.** The stub POST does not go through the
OmniAuth stack, so it relies on whatever your `ApplicationController` does. If
you have set `allow_forgery_protection = false` in `development.rb`, any web
page you visit can silently sign you into your local admin panel.

### Hosts with a custom login view

If your app ships its own `app/views/active_admin/devise/sessions/new.html.erb`,
the engine backs off and your view wins, so you render the button yourself.
Everything you need is on the config object — no helpers to mix in:

```erb
<% if ActiveAdmin::Oidc.config.stub_dev_env_login_enabled? %>
<p>Stub login is enabled: this button never contacts the identity provider.</p>
<% end %>

<%= button_to ActiveAdmin::Oidc.config.login_button_label,
ActiveAdmin::Oidc.config.login_submit_path,
method: :post, data: { turbo: false } %>
```

`login_submit_path` returns the stub route while stub login is on and the real
OmniAuth entry point otherwise.

## Custom login view

The gem ships a minimal SSO-only login page (a single button, no email/password fields). If you need a different layout — for instance, different branding, an explanatory paragraph, or multiple OmniAuth strategies — drop your own template at:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,58 @@ def oidc
claims['sub'] = auth['uid'] if claims['sub'].blank? && auth['uid'].present?
claims['email'] = info['email'] if claims['email'].blank? && info['email'].present?

provision_and_sign_in(claims)
end

# Development stub login: sign in with locally fabricated claims
# instead of a real authorization code flow, for machines whose
# redirect URI the IdP does not know. Deliberately runs the SAME
# provisioning path as `#oidc` -- identity lookup, the takeover
# guard, the host's `on_login` hook, oidc_raw_info, and the
# active_for_authentication? check all still apply, so what works
# locally is what works against the real IdP. In particular, a
# claims block that does not satisfy `on_login` is denied here
# exactly as the real IdP would deny it.
#
# The route only exists when stub login is enabled, and
# `stub_dev_env_login!` only enables it in the development
# environment. The check here covers a flag flipped at runtime.
def stub
cfg = ActiveAdmin::Oidc.config
unless cfg.stub_dev_env_login_enabled?
head :not_found
return
end

claims = cfg.stub_dev_env_login_claims

ActiveAdmin::Oidc.logger.warn(
"[activeadmin-oidc] STUB LOGIN used for sub=#{claims['sub'].inspect} " \
'-- no identity provider was contacted.'
)

provision_and_sign_in(claims, kind: 'stub login (no IdP)')
end

def failure
Rails.logger.warn("[activeadmin-oidc] omniauth failure: #{failure_message}")
flash[:alert] = ActiveAdmin::Oidc.config.access_denied_message
redirect_to after_omniauth_failure_path_for(resource_name)
end

private

# Shared tail of every sign-in path: turn a claims hash into a
# persisted, signed-in admin user, or into a denial flash.
def provision_and_sign_in(claims, kind: 'OIDC')
admin_user = UserProvisioner.new(
ActiveAdmin::Oidc.config,
claims: claims,
provider: ActiveAdmin::Oidc::Engine::PROVIDER_NAME.to_s
).call

sign_in_and_redirect admin_user, event: :authentication
set_flash_message(:notice, :success, kind: 'OIDC') if is_navigational_format?
set_flash_message(:notice, :success, kind: kind) if is_navigational_format?
rescue ActiveAdmin::Oidc::InactiveError => e
Rails.logger.warn("[activeadmin-oidc] inactive: #{e.inactive_message_key}")
# Fall back to the standard `inactive` translation rather
Expand All @@ -57,14 +101,6 @@ def oidc
redirect_to after_omniauth_failure_path_for(resource_name)
end

def failure
Rails.logger.warn("[activeadmin-oidc] omniauth failure: #{failure_message}")
flash[:alert] = ActiveAdmin::Oidc.config.access_denied_message
redirect_to after_omniauth_failure_path_for(resource_name)
end

private

# Land on the ActiveAdmin namespace root after a successful SSO
# sign-in instead of Devise's default (host app root). Hosts
# that don't define a `/` route would otherwise hit a routing
Expand Down
16 changes: 14 additions & 2 deletions app/views/active_admin/devise/sessions/new.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,14 @@
<%= site_title %> <%= set_page_title t('active_admin.devise.login.title') %>
</h2>

<% if ActiveAdmin::Oidc.config.stub_dev_env_login_enabled? %>
<p class="activeadmin-oidc-stub-login text-sm font-semibold border border-red-400 bg-red-50 text-red-900 rounded-md p-3 dark:bg-red-900/30 dark:border-red-700 dark:text-red-100">
Stub login is enabled: the button below signs in without contacting the identity provider.
</p>
<% end %>

<%= button_to ActiveAdmin::Oidc.config.login_button_label,
"#{OmniAuth.config.path_prefix}/oidc",
ActiveAdmin::Oidc.config.login_submit_path,
method: :post,
class: "activeadmin-oidc-login-button w-full",
form_class: 'formtastic',
Expand All @@ -15,7 +21,13 @@
<div id="login">
<h2><%= active_admin_application.site_title(self) %></h2>

<%= form_tag "#{OmniAuth.config.path_prefix}/oidc",
<% if ActiveAdmin::Oidc.config.stub_dev_env_login_enabled? %>
<p class="activeadmin-oidc-stub-login" style="margin-top:1em;padding:0.75em;border:1px solid #c00;background:#fff3f3;color:#900;font-weight:bold">
Stub login is enabled: the button below signs in without contacting the identity provider.
</p>
<% end %>

<%= form_tag ActiveAdmin::Oidc.config.login_submit_path,
method: :post,
class: "activeadmin-oidc-login-form formtastic",
data: { turbo: false } do %>
Expand Down
66 changes: 66 additions & 0 deletions lib/activeadmin/oidc/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ class Configuration
'Your account has no permission to access this admin panel.'
DEFAULT_LOGIN_PATH = '/admin/login'
DEFAULT_LOGOUT_PATH = '/admin/logout'
DEFAULT_STUB_DEV_ENV_LOGIN_CLAIMS = {
'sub' => 'stub-uid',
'email' => 'stub-dev@example.com'
}.freeze

attr_accessor :issuer, :client_id, :client_secret, :scope,
:redirect_uri,
Expand All @@ -21,6 +25,12 @@ class Configuration
:access_denied_message, :on_login, :admin_user_class,
:login_path, :logout_path

# Readers, not writers: stub login is turned on through
# `stub_dev_env_login!` so the environment check cannot be skipped.
# Specs (this gem's own included) stub these two instead of
# pretending to run in the development environment.
attr_reader :stub_dev_env_login_claims_block

def initialize
reset!
end
Expand All @@ -41,6 +51,8 @@ def reset!
@logout_path = DEFAULT_LOGOUT_PATH
@on_login = nil
@pkce_override = nil
@stub_dev_env_login = false
@stub_dev_env_login_claims_block = nil
self
end

Expand All @@ -54,6 +66,60 @@ def pkce=(value)
@pkce_override = value
end

# Turns on the development stub login: the login page's button
# signs in with locally fabricated claims instead of redirecting to
# the IdP. For machines whose redirect URI the IdP does not know --
# a non-default port, or two apps sharing one OIDC client.
#
# A no-op outside the development environment, so there is nothing
# to guard at boot and nothing to flip off before a deploy.
#
# The optional block receives the default claims and returns the
# claims to sign in with, so a host whose `on_login` reads roles or
# groups can satisfy it:
#
# c.stub_dev_env_login! { |claims| claims.merge('groups' => ADMIN_GROUP) }
#
# The claims go through the same UserProvisioner as a real
# callback, so a block that does not satisfy `on_login` is denied
# exactly as the real IdP would deny it.
def stub_dev_env_login!(&block)
return false unless ::Rails.env.development?

@stub_dev_env_login = true
@stub_dev_env_login_claims_block = block
true
end

def stub_dev_env_login_enabled?
@stub_dev_env_login
end

# Evaluated once per stub sign-in, in the controller. String keys
# all the way down, the same shape `on_login` receives from a real
# callback.
#
# The block may either return a Hash or mutate the one it is given
# -- `claims["groups"] = [...]` as a last line returns the assigned
# value, not the Hash, and that should not 500 the dev's login.
def stub_dev_env_login_claims
claims = DEFAULT_STUB_DEV_ENV_LOGIN_CLAIMS.dup
block = stub_dev_env_login_claims_block
if block
returned = block.call(claims)
claims = returned if returned.is_a?(Hash)
end
claims.deep_transform_keys(&:to_s)
end

# Where the login page's single button POSTs to: the stub route
# while stub login is on, the real OmniAuth entry point otherwise.
def login_submit_path
return "#{login_path}/stub" if stub_dev_env_login_enabled?

"#{::OmniAuth.config.path_prefix}/#{Engine::PROVIDER_NAME}"
end

def validate!
raise ConfigurationError, 'issuer is required' if issuer.blank?
raise ConfigurationError, 'client_id is required' if client_id.blank?
Expand Down
33 changes: 30 additions & 3 deletions lib/activeadmin/oidc/engine.rb
Original file line number Diff line number Diff line change
Expand Up @@ -145,9 +145,12 @@ def controllers
app.config.after_initialize do
next unless Engine.oidc_enabled?

cfg = ActiveAdmin::Oidc.config
login_path = cfg.login_path
logout_path = cfg.logout_path
# Captured here, not inside the append block: on ActiveAdmin 4
# routes are drawn lazily on the first request, long after the
# host initializer that set these ran, so a draw-time read can
# observe a config that something else has since replaced.
login_path = ActiveAdmin::Oidc.config.login_path
logout_path = ActiveAdmin::Oidc.config.logout_path
scope_name = Engine.admin_user_class.model_name.singular.to_sym

Engine.session_routes_target(app).append do
Expand All @@ -174,6 +177,30 @@ def controllers
logout_via = [*::Devise.sign_out_via, aa_method].compact.uniq

match logout_path, to: ::ActiveAdmin::Devise::SessionsController.action(:destroy), as: :"destroy_#{scope_name}_session", via: logout_via

# Development stub login. `stub_dev_env_login!` only ever
# sets the flag in the development environment, so the
# route simply does not exist anywhere else -- and a route
# that is never drawn cannot be probed.
# Read through `ActiveAdmin::Oidc.config` at draw time rather
# than from a captured object: `reset!` swaps the whole
# Configuration instance, and a redraw must see the current
# one.
stub_cfg = ActiveAdmin::Oidc.config

if stub_cfg.stub_dev_env_login_enabled?
# Resolve the controller per request instead of capturing
# `.action(:stub)` at draw time: the class lives in the
# engine's app/ and is reloadable, and stub login runs in
# development where a captured constant goes stale on the
# first reload. A lambda also sidesteps the module scoping
# an isolated engine would apply to a string target.
# `login_submit_path` is what the login button posts to,
# so drawing the route from it keeps the two in step.
post stub_cfg.login_submit_path,
to: ->(env) { ::ActiveAdmin::Oidc::Devise::OmniauthCallbacksController.action(:stub).call(env) },
as: :"#{scope_name}_stub_login"
end
end
end
end
Expand Down
2 changes: 1 addition & 1 deletion lib/activeadmin/oidc/version.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@

module ActiveAdmin
module Oidc
VERSION = "2.1.3"
VERSION = "2.2.0"
end
end
Loading
Loading