added authentication schemes

This commit is contained in:
Chris
2025-11-21 10:24:18 -08:00
parent d94868e3df
commit 79077f45a8
37 changed files with 896 additions and 12 deletions

View File

@@ -82,6 +82,7 @@ gem "pagy", "~> 9.4"
gem "oj", "~> 3.16"
gem "omniauth", "~> 2.1"
gem "omniauth-rails_csrf_protection", "~> 1.0"
gem "omniauth_openid_connect", "~> 0.8"
gem "annotate", "~> 3.2"

View File

@@ -97,10 +97,12 @@ GEM
tzinfo (~> 2.0, >= 2.0.5)
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
aes_key_wrap (1.1.0)
annotate (3.2.0)
activerecord (>= 3.2, < 8.0)
rake (>= 10.4, < 14.0)
ast (2.4.3)
attr_required (1.0.2)
avo (3.25.3)
actionview (>= 6.1)
active_link_to
@@ -122,6 +124,7 @@ GEM
bcrypt (3.1.20)
benchmark (0.5.0)
bigdecimal (3.3.1)
bindata (2.5.1)
bindex (0.8.1)
bootsnap (1.18.6)
msgpack (~> 1.2)
@@ -192,6 +195,8 @@ GEM
dry-inflector (~> 1.0)
dry-logic (~> 1.4)
zeitwerk (~> 2.6)
email_validator (2.2.4)
activemodel
erb (5.1.3)
erubi (1.13.1)
et-orbi (1.4.0)
@@ -208,6 +213,8 @@ GEM
faraday-net_http (>= 2.0, < 3.5)
json
logger
faraday-follow_redirects (0.4.0)
faraday (>= 1, < 3)
faraday-net_http (3.4.1)
net-http (>= 0.5.0)
ffi (1.17.0-aarch64-linux-gnu)
@@ -284,6 +291,13 @@ GEM
jsbundling-rails (1.3.1)
railties (>= 6.0.0)
json (2.13.2)
json-jwt (1.17.0)
activesupport (>= 4.2)
aes_key_wrap
base64
bindata
faraday (~> 2.0)
faraday-follow_redirects
jsonpath (1.1.5)
multi_json
jwt (2.9.1)
@@ -398,6 +412,22 @@ GEM
omniauth-rails_csrf_protection (1.0.2)
actionpack (>= 4.2)
omniauth (~> 2.0)
omniauth_openid_connect (0.8.0)
omniauth (>= 1.9, < 3)
openid_connect (~> 2.2)
openid_connect (2.3.1)
activemodel
attr_required (>= 1.0.0)
email_validator
faraday (~> 2.0)
faraday-follow_redirects
json-jwt (>= 1.16)
mail
rack-oauth2 (~> 2.2)
swd (~> 2.0)
tzinfo
validate_url
webfinger (~> 2.0)
orm_adapter (0.5.0)
ostruct (0.6.2)
pagy (9.4.0)
@@ -432,6 +462,13 @@ GEM
raabro (1.4.0)
racc (1.8.1)
rack (3.1.18)
rack-oauth2 (2.3.0)
activesupport
attr_required
faraday (~> 2.0)
faraday-follow_redirects
json-jwt (>= 1.11.0)
rack (>= 2.1.0)
rack-protection (4.0.0)
base64 (>= 0.1.0)
rack (>= 3.0.0, < 4)
@@ -583,6 +620,11 @@ GEM
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.1.7)
swd (2.0.3)
activesupport (>= 3)
attr_required (>= 0.0.5)
faraday (~> 2.0)
faraday-follow_redirects
sys-proctable (1.3.0)
ffi (~> 1.1)
tailwindcss-rails (2.7.6)
@@ -613,6 +655,9 @@ GEM
unicode-emoji (4.0.4)
uri (1.0.3)
useragent (0.16.11)
validate_url (1.0.15)
activemodel (>= 3.0.0)
public_suffix
version_gem (1.1.4)
view_component (4.1.0)
activesupport (>= 7.1.0, < 8.2)
@@ -624,6 +669,10 @@ GEM
activemodel (>= 6.0.0)
bindex (>= 0.4.0)
railties (>= 6.0.0)
webfinger (2.1.3)
activesupport
faraday (~> 2.0)
faraday-follow_redirects
webmock (3.26.1)
addressable (>= 2.8.0)
crack (>= 0.3.2)
@@ -683,6 +732,7 @@ DEPENDENCIES
omniauth-github (~> 2.0)
omniauth-gitlab (~> 4.1)
omniauth-rails_csrf_protection (~> 1.0)
omniauth_openid_connect (~> 0.8)
pagy (~> 9.4)
pg (~> 1.6)
pretender (~> 0.6.0)

View File

@@ -0,0 +1,21 @@
class Avo::Resources::LdapConfiguration < Avo::BaseResource
# self.includes = []
# self.attachments = []
# self.search = {
# query: -> { query.ransack(id_eq: q, m: "or").result(distinct: false) }
# }
def fields
field :id, as: :id
field :host, as: :text
field :port, as: :number
field :encryption, as: :text
field :base_dn, as: :text
field :bind_dn, as: :text
field :bind_password, as: :text
field :uid_attribute, as: :text
field :email_attribute, as: :text
field :name_attribute, as: :text
field :filter, as: :text
end
end

View File

@@ -0,0 +1,19 @@
class Avo::Resources::OIDCConfiguration < Avo::BaseResource
# self.includes = []
# self.attachments = []
# self.search = {
# query: -> { query.ransack(id_eq: q, m: "or").result(distinct: false) }
# }
def fields
field :id, as: :id
field :issuer, as: :text
field :client_id, as: :text
field :client_secret, as: :text
field :authorization_endpoint, as: :text
field :token_endpoint, as: :text
field :userinfo_endpoint, as: :text
field :jwks_uri, as: :text
field :scopes, as: :text
end
end

View File

@@ -0,0 +1,15 @@
class Avo::Resources::SSOProvider < Avo::BaseResource
# self.includes = []
# self.attachments = []
# self.search = {
# query: -> { query.ransack(id_eq: q, m: "or").result(distinct: false) }
# }
def fields
field :id, as: :id
field :account, as: :belongs_to
field :configuration, as: :text
field :name, as: :text
field :enabled, as: :boolean
end
end

View File

@@ -0,0 +1,71 @@
module Accounts
class SSOProvidersController < ApplicationController
def show
@sso_provider = current_account.sso_provider
@oidc_configuration = @sso_provider&.configuration
end
def new
@sso_provider = current_account.build_sso_provider
@oidc_configuration = OIDCConfiguration.new
end
def create
@oidc_configuration = OIDCConfiguration.new(oidc_configuration_params)
@sso_provider = current_account.build_sso_provider(sso_provider_params)
@sso_provider.configuration = @oidc_configuration
if @oidc_configuration.save && @sso_provider.save
redirect_to sso_provider_path, notice: "SSO provider created successfully"
else
render :new, status: :unprocessable_entity
end
end
def edit
@sso_provider = current_account.sso_provider
redirect_to new_sso_provider_path, alert: "No SSO provider configured" unless @sso_provider
@oidc_configuration = @sso_provider&.configuration
end
def update
@sso_provider = current_account.sso_provider
@oidc_configuration = @sso_provider.configuration
if @oidc_configuration.update(oidc_configuration_params) && @sso_provider.update(sso_provider_params)
redirect_to sso_provider_path, notice: "SSO provider updated successfully"
else
render :edit, status: :unprocessable_entity
end
end
def destroy
@sso_provider = current_account.sso_provider
if @sso_provider&.destroy
redirect_to sso_provider_path, notice: "SSO provider deleted"
else
redirect_to sso_provider_path, alert: "Failed to delete SSO provider"
end
end
private
def sso_provider_params
params.require(:sso_provider).permit(:name, :enabled)
end
def oidc_configuration_params
params.require(:oidc_configuration).permit(
:issuer,
:client_id,
:client_secret,
:authorization_endpoint,
:token_endpoint,
:userinfo_endpoint,
:jwks_uri,
:scopes
)
end
end
end

View File

@@ -0,0 +1,4 @@
# This controller has been generated to enable Rails' resource routes.
# More information on https://docs.avohq.io/3.0/controllers.html
class Avo::LdapConfigurationsController < Avo::ResourcesController
end

View File

@@ -0,0 +1,4 @@
# This controller has been generated to enable Rails' resource routes.
# More information on https://docs.avohq.io/3.0/controllers.html
class Avo::OIDCConfigurationsController < Avo::ResourcesController
end

View File

@@ -0,0 +1,4 @@
# This controller has been generated to enable Rails' resource routes.
# More information on https://docs.avohq.io/3.0/controllers.html
class Avo::SSOProvidersController < Avo::ResourcesController
end

View File

@@ -1,7 +1,7 @@
module Users
class OmniauthCallbacksController < Devise::OmniauthCallbacksController
before_action :set_provider, except: [ :failure ]
before_action :set_user, except: [ :failure ]
before_action :set_provider, except: [ :failure, :oidc ]
before_action :set_user, except: [ :failure, :oidc ]
attr_reader :provider, :user
@@ -13,8 +13,46 @@ module Users
handle_auth "Github"
end
def oidc
sso_provider_id = session["sso_provider_id"]
sso_provider = SSOProvider.find_by(id: sso_provider_id) if sso_provider_id.present?
if sso_provider
@user = find_or_create_oidc_user
handle_oidc_auth(sso_provider)
else
redirect_to root_path, alert: "SSO provider not found"
end
end
private
def find_or_create_oidc_user
if user_signed_in?
current_user
else
# Find or create user from OIDC data
create_user
end
end
def handle_oidc_auth(sso_provider)
# For OIDC, we don't store in Provider model, just authenticate
if user_signed_in?
flash[:notice] = "Your #{sso_provider.name} account was connected."
redirect_to edit_user_registration_path
else
sign_in_and_redirect @user, event: :authentication
# Set account from SSO provider
session[:account_id] = sso_provider.account_id
set_flash_message :notice, :success, kind: sso_provider.name
end
# Clear SSO session data
session.delete("sso_provider_id")
session.delete("sso_account_id")
end
def handle_auth(kind)
if provider.present?
provider.update(provider_attrs)

View File

@@ -40,6 +40,7 @@ class Users::SessionsController < Devise::SessionsController
def account_login
self.resource = resource_class.new(sign_in_params)
clean_up_passwords(resource)
@sso_provider = @account.sso_provider if @account.sso_enabled?
if @account.stack_manager&.portainer?
render "devise/sessions/portainer"
else

View File

@@ -26,6 +26,7 @@ class Account < ApplicationRecord
has_many :account_users, dependent: :destroy
has_many :users, through: :account_users
has_one :stack_manager, dependent: :destroy
has_one :sso_provider, dependent: :destroy
has_many :clusters, dependent: :destroy
has_many :build_clouds, through: :clusters
@@ -47,4 +48,8 @@ class Account < ApplicationRecord
def github_provider
@_github_account ||= owner.providers.find_by(provider: "github")
end
def sso_enabled?
sso_provider&.enabled?
end
end

View File

@@ -0,0 +1,43 @@
# == Schema Information
#
# Table name: ldap_configurations
#
# id :bigint not null, primary key
# base_dn :string not null
# bind_dn :string
# bind_password :string
# email_attribute :string default("mail")
# encryption :string default("plain")
# filter :string
# host :string not null
# name_attribute :string default("cn")
# port :integer default(389), not null
# uid_attribute :string default("uid"), not null
# created_at :datetime not null
# updated_at :datetime not null
#
class LdapConfiguration < ApplicationRecord
has_one :sso_provider, as: :configuration, dependent: :destroy
validates :host, presence: true
validates :port, presence: true, numericality: { only_integer: true, greater_than: 0 }
validates :base_dn, presence: true
validates :uid_attribute, presence: true
validates :encryption, inclusion: { in: %w[plain simple_tls start_tls] }
# Encryption options: plain (no encryption), simple_tls (LDAPS), start_tls (STARTTLS)
def encryption_method
case encryption
when "simple_tls"
:simple_tls
when "start_tls"
:start_tls
else
nil
end
end
def requires_auth?
bind_dn.present? && bind_password.present?
end
end

View File

@@ -0,0 +1,19 @@
# == Schema Information
#
# Table name: oidc_configurations
#
# id :bigint not null, primary key
# authorization_endpoint :string
# client_secret :string not null
# issuer :string not null
# jwks_uri :string
# scopes :string default("openid email profile")
# token_endpoint :string
# userinfo_endpoint :string
# created_at :datetime not null
# updated_at :datetime not null
# client_id :string not null
#
class OIDCConfiguration < ApplicationRecord
has_one :sso_provider, as: :configuration, dependent: :destroy
end

View File

@@ -0,0 +1,36 @@
# == Schema Information
#
# Table name: sso_providers
#
# id :bigint not null, primary key
# configuration_type :string not null
# enabled :boolean default(TRUE), not null
# name :string not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# configuration_id :bigint not null
#
# Indexes
#
# index_sso_providers_on_account_id (account_id) UNIQUE
# index_sso_providers_on_configuration (configuration_type,configuration_id)
#
# Foreign Keys
#
# fk_rails_... (account_id => accounts.id)
#
class SSOProvider < ApplicationRecord
belongs_to :account
belongs_to :configuration, polymorphic: true
validates :account_id, uniqueness: true
def oidc?
configuration_type == "OIDCConfiguration"
end
def ldap?
configuration_type == "LdapConfiguration"
end
end

View File

@@ -0,0 +1,98 @@
<%= form_with model: sso_provider, url: sso_provider.persisted? ? sso_provider_path : sso_provider_path do |form| %>
<%= render "shared/error_messages", resource: form.object %>
<div class="form-control mt-4 w-full max-w-sm">
<label class="label">
<span class="label-text">Provider Name</span>
</label>
<%= form.text_field :name, class: "input input-bordered", required: true, placeholder: "e.g., Company SSO" %>
<label class="label">
<span class="label-text-alt">A friendly name for this SSO provider</span>
</label>
</div>
<div class="form-control mt-1 w-full max-w-sm">
<label class="label cursor-pointer justify-start gap-2">
<%= form.check_box :enabled, class: "checkbox checkbox-primary" %>
<span class="label-text">Enable this provider</span>
</label>
</div>
<div class="divider">OIDC Configuration</div>
<%= fields_for :oidc_configuration, oidc_configuration do |oidc_form| %>
<div class="form-control mt-1 w-full max-w-sm">
<label class="label">
<span class="label-text">Issuer URL <span class="text-error">*</span></span>
</label>
<%= oidc_form.text_field :issuer, class: "input input-bordered", required: true, placeholder: "https://your-idp.com" %>
<label class="label">
<span class="label-text-alt">The base URL of your OIDC provider</span>
</label>
</div>
<div class="form-control mt-1 w-full max-w-sm">
<label class="label">
<span class="label-text">Client ID <span class="text-error">*</span></span>
</label>
<%= oidc_form.text_field :client_id, class: "input input-bordered", required: true %>
</div>
<div class="form-control mt-1 w-full max-w-sm">
<label class="label">
<span class="label-text">Client Secret <span class="text-error">*</span></span>
</label>
<%= oidc_form.password_field :client_secret, class: "input input-bordered", required: true %>
</div>
<div class="form-control mt-1 w-full max-w-sm">
<label class="label">
<span class="label-text">Scopes</span>
</label>
<%= oidc_form.text_field :scopes, class: "input input-bordered", placeholder: "openid email profile" %>
<label class="label">
<span class="label-text-alt">Space-separated list of scopes (defaults to "openid email profile")</span>
</label>
</div>
<details class="collapse collapse-arrow bg-base-200 mt-4 max-w-sm">
<summary class="collapse-title font-medium">
Advanced Settings (Optional)
</summary>
<div class="collapse-content space-y-4">
<div class="form-control mt-1 w-full">
<label class="label">
<span class="label-text">Authorization Endpoint</span>
</label>
<%= oidc_form.text_field :authorization_endpoint, class: "input input-bordered", placeholder: "Auto-discovered if not set" %>
</div>
<div class="form-control mt-1 w-full">
<label class="label">
<span class="label-text">Token Endpoint</span>
</label>
<%= oidc_form.text_field :token_endpoint, class: "input input-bordered", placeholder: "Auto-discovered if not set" %>
</div>
<div class="form-control mt-1 w-full">
<label class="label">
<span class="label-text">UserInfo Endpoint</span>
</label>
<%= oidc_form.text_field :userinfo_endpoint, class: "input input-bordered", placeholder: "Auto-discovered if not set" %>
</div>
<div class="form-control mt-1 w-full">
<label class="label">
<span class="label-text">JWKS URI</span>
</label>
<%= oidc_form.text_field :jwks_uri, class: "input input-bordered", placeholder: "Auto-discovered if not set" %>
</div>
</div>
</details>
<% end %>
<div class="form-footer mt-6">
<%= form.submit sso_provider.persisted? ? "Update Provider" : "Create Provider", class: "btn btn-primary" %>
<%= link_to "Cancel", sso_provider_path, class: "btn btn-outline" %>
</div>
<% end %>

View File

@@ -0,0 +1,13 @@
<%= settings_layout do %>
<%= turbo_frame_tag "sso_provider" do %>
<div class="font-lg font-bold mt-4">
Edit <%= @sso_provider.name %>
</div>
<div class="text-sm text-gray-500 mt-2 mb-4">
Update your OIDC provider configuration.
</div>
<%= render "accounts/sso_providers/form", sso_provider: @sso_provider, oidc_configuration: @oidc_configuration %>
<% end %>
<% end %>

View File

@@ -0,0 +1,13 @@
<%= settings_layout do %>
<%= turbo_frame_tag "sso_provider" do %>
<div class="font-lg font-bold mt-4">
Add OIDC Provider
</div>
<div class="text-sm text-gray-500 mt-2 mb-4">
Configure an OpenID Connect (OIDC) provider for SSO authentication. This will allow users to sign in using your organization's identity provider.
</div>
<%= render "accounts/sso_providers/form", sso_provider: @sso_provider, oidc_configuration: @oidc_configuration %>
<% end %>
<% end %>

View File

@@ -0,0 +1,55 @@
<%= settings_layout do %>
<h2 class="text-2xl font-bold">Authentication</h2>
<hr class="mt-3 mb-4 border-t border-base-300" />
<%= turbo_frame_tag "sso_provider" do %>
<% if @sso_provider.present? %>
<div class="card bg-base-200 mb-4">
<div class="card-body">
<div class="flex justify-between items-start">
<div>
<h3 class="text-lg font-semibold"><%= @sso_provider.name %></h3>
<div class="flex gap-2 mt-2">
<span class="badge badge-outline">OIDC</span>
<% if @sso_provider.enabled %>
<span class="badge badge-success">Enabled</span>
<% else %>
<span class="badge badge-ghost">Disabled</span>
<% end %>
</div>
</div>
<div class="flex gap-2">
<%= link_to "Edit", edit_sso_provider_path, class: "btn btn-sm btn-outline" %>
<%= button_to "Delete", sso_provider_path, method: :delete, class: "btn btn-sm btn-error", data: { turbo_confirm: "Are you sure you want to delete this SSO provider?" } %>
</div>
</div>
<% if @oidc_configuration %>
<div class="divider"></div>
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<span class="text-gray-500">Issuer:</span>
<div class="font-mono"><%= @oidc_configuration.issuer %></div>
</div>
<div>
<span class="text-gray-500">Client ID:</span>
<div class="font-mono"><%= @oidc_configuration.client_id %></div>
</div>
<div>
<span class="text-gray-500">Scopes:</span>
<div class="font-mono"><%= @oidc_configuration.scopes || "openid email profile" %></div>
</div>
</div>
<% end %>
</div>
</div>
<% else %>
<div class="alert mb-4">
<iconify-icon icon="lucide:info" class="mr-2"></iconify-icon>
<span>No SSO provider configured for this account.</span>
</div>
<%= link_to "+ Add OIDC Provider", new_sso_provider_path, class: "btn btn-primary btn-sm" %>
<% end %>
<% end %>
<% end %>

View File

@@ -41,13 +41,27 @@
<%= f.submit "Sign in", class: "btn btn-primary w-full" %>
</div>
<% end %>
<% unless Rails.application.config.local_mode %>
<% if defined?(@sso_provider) && @sso_provider.present? %>
<div class="flex items-center my-4">
<div class="flex-grow border-t border-gray-600"></div>
<span class="mx-4 text-gray-500">OR</span>
<div class="flex-grow border-t border-gray-600"></div>
</div>
<%= button_to "Sign in with #{@sso_provider.name}",
user_oidc_omniauth_authorize_path(sso_provider_id: @sso_provider.id),
class: "btn btn-outline w-full",
data: { turbo: false } %>
<% end %>
<% unless Rails.application.config.local_mode %>
<% unless defined?(@sso_provider) && @sso_provider.present? %>
<div class="flex items-center my-4">
<div class="flex-grow border-t border-gray-600"></div>
<span class="mx-4 text-gray-500">OR</span>
<div class="flex-grow border-t border-gray-600"></div>
</div>
<% end %>
<%= render "devise/shared/links" %>
<% end %>
</div>

View File

@@ -39,6 +39,12 @@
Credentials
<% end %>
</li>
<li class="<%= 'underline' if current_page?(sso_provider_path) || current_page?(new_sso_provider_path) || current_page?(edit_sso_provider_path) %>" style="text-underline-offset: 0.25rem;">
<%= link_to sso_provider_path do %>
<iconify-icon icon="lucide:shield-check" ></iconify-icon>
Authentication
<% end %>
</li>
<% if Flipper.enabled?(:stack_manager, current_account) %>
<li class="<%= 'underline' if controller_name == 'stack_managers' %>" style="text-underline-offset: 0.25rem;">
<%= link_to stack_manager_path do %>

View File

@@ -274,14 +274,21 @@ Devise.setup do |config|
# TODO (chris): add digital ocean?
config.omniauth :github, ENV["OMNIAUTH_GITHUB_PUBLIC_KEY"], ENV["OMNIAUTH_GITHUB_PRIVATE_KEY"], scope: "user,repo,write:packages,read:org"
config.omniauth :developer if Rails.env.test?
# Dynamic OIDC provider
require Rails.root.join("lib/omniauth/strategies/dynamic_oidc")
config.omniauth :oidc, strategy_class: OmniAuth::Strategies::DynamicOIDC
# ==> Warden configuration
# If you want to use other strategies, that are not supported by Devise, or
# change the failure app, you can configure them inside the config.warden block.
#
# config.warden do |manager|
# manager.intercept_401 = false
# manager.default_strategies(scope: :user).unshift :some_external_strategy
# end
# Load custom LDAP authentication strategy
require Rails.root.join('lib', 'devise', 'strategies', 'ldap_authenticatable')
config.warden do |manager|
manager.strategies.add(:ldap_authenticatable, Devise::Strategies::LdapAuthenticatable)
end
# ==> Mountable engine configurations
# When using Devise inside an engine, let's call it `MyEngine`, and this engine

View File

@@ -11,6 +11,7 @@
# end
# These inflection rules are supported but not enabled by default:
# ActiveSupport::Inflector.inflections(:en) do |inflect|
# inflect.acronym "RESTful"
# end
ActiveSupport::Inflector.inflections(:en) do |inflect|
inflect.acronym "OIDC"
inflect.acronym "SSO"
end

View File

@@ -19,6 +19,7 @@ Rails.application.routes.draw do
resources :accounts, only: [ :create ] do
collection do
resources :account_users, only: %i[create index destroy], module: :accounts
resource :sso_provider, only: %i[show new create edit update destroy], module: :accounts
end
member do
get :switch

View File

@@ -0,0 +1,12 @@
class CreateSSOProviders < ActiveRecord::Migration[7.2]
def change
create_table :sso_providers do |t|
t.references :account, null: false, foreign_key: true, index: { unique: true }
t.references :configuration, polymorphic: true, null: false
t.string :name, null: false
t.boolean :enabled, default: true, null: false
t.timestamps
end
end
end

View File

@@ -0,0 +1,16 @@
class CreateOIDCConfigurations < ActiveRecord::Migration[7.2]
def change
create_table :oidc_configurations do |t|
t.string :issuer, null: false
t.string :client_id, null: false
t.string :client_secret, null: false
t.string :authorization_endpoint
t.string :token_endpoint
t.string :userinfo_endpoint
t.string :jwks_uri
t.string :scopes, default: "openid email profile"
t.timestamps
end
end
end

View File

@@ -0,0 +1,18 @@
class CreateLdapConfigurations < ActiveRecord::Migration[7.2]
def change
create_table :ldap_configurations do |t|
t.string :host, null: false
t.integer :port, default: 389, null: false
t.string :encryption, default: "plain"
t.string :base_dn, null: false
t.string :bind_dn
t.string :bind_password
t.string :uid_attribute, default: "uid", null: false
t.string :email_attribute, default: "mail"
t.string :name_attribute, default: "cn"
t.string :filter
t.timestamps
end
end
end

28
db/schema.rb generated
View File

@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2025_11_16_091324) do
ActiveRecord::Schema[7.2].define(version: 2025_11_21_024136) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@@ -375,6 +375,19 @@ ActiveRecord::Schema[7.2].define(version: 2025_11_16_091324) do
t.index ["recipient_type", "recipient_id"], name: "index_noticed_notifications_on_recipient"
end
create_table "oidc_configurations", force: :cascade do |t|
t.string "issuer", null: false
t.string "client_id", null: false
t.string "client_secret", null: false
t.string "authorization_endpoint"
t.string "token_endpoint"
t.string "userinfo_endpoint"
t.string "jwks_uri"
t.string "scopes", default: "openid email profile"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
create_table "project_add_ons", force: :cascade do |t|
t.bigint "project_id", null: false
t.bigint "add_on_id", null: false
@@ -477,6 +490,18 @@ ActiveRecord::Schema[7.2].define(version: 2025_11_16_091324) do
t.index ["project_id"], name: "index_services_on_project_id"
end
create_table "sso_providers", force: :cascade do |t|
t.bigint "account_id", null: false
t.string "configuration_type", null: false
t.bigint "configuration_id", null: false
t.string "name", null: false
t.boolean "enabled", default: true, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id"], name: "index_sso_providers_on_account_id", unique: true
t.index ["configuration_type", "configuration_id"], name: "index_sso_providers_on_configuration"
end
create_table "stack_managers", force: :cascade do |t|
t.string "provider_url", null: false
t.integer "stack_manager_type", default: 0, null: false
@@ -544,6 +569,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_11_16_091324) do
add_foreign_key "projects", "clusters", column: "project_fork_cluster_id"
add_foreign_key "providers", "users"
add_foreign_key "services", "projects"
add_foreign_key "sso_providers", "accounts"
add_foreign_key "stack_managers", "accounts"
add_foreign_key "volumes", "projects"
end

View File

@@ -0,0 +1,79 @@
require 'net/ldap'
require 'devise/strategies/authenticatable'
module Devise
module Strategies
class LdapAuthenticatable < Authenticatable
def authenticate!
if params[:user]
ldap_config = find_ldap_configuration
return fail(:invalid_login) unless ldap_config
ldap = Net::LDAP.new(
host: ldap_config.host,
port: ldap_config.port,
encryption: ldap_config.encryption_method
)
# Build the user DN for authentication
user_dn = build_user_dn(ldap_config, email)
ldap.auth user_dn, password
if ldap.bind
# LDAP authentication successful, find or create user
user = User.find_or_create_by(email: email) do |u|
u.password = SecureRandom.hex(32) # Set random password since LDAP handles auth
u.account = find_or_create_account_for_ldap(ldap_config)
end
success!(user)
else
Rails.logger.info "LDAP bind failed for #{email}: #{ldap.get_operation_result.message}"
return fail(:invalid_login)
end
end
end
def email
params[:user][:email]
end
def password
params[:user][:password]
end
private
def find_ldap_configuration
# Find the LDAP configuration for the account
# This could be based on email domain or a selection during login
account_id = session[:ldap_account_id] || params[:account_id]
if account_id
sso_provider = SSOProvider.find_by(account_id: account_id, enabled: true)
return sso_provider.configuration if sso_provider&.ldap?
end
# Fallback: try to find by email domain or return first enabled LDAP config
LdapConfiguration.joins(:sso_provider)
.where(sso_providers: { enabled: true })
.first
end
def build_user_dn(ldap_config, email)
# Extract username from email if needed
username = email.split('@').first
# Build the DN using the uid attribute and base DN
"#{ldap_config.uid_attribute}=#{username},#{ldap_config.base_dn}"
end
def find_or_create_account_for_ldap(ldap_config)
sso_provider = ldap_config.sso_provider
sso_provider&.account
end
end
end
end
Warden::Strategies.add(:ldap_authenticatable, Devise::Strategies::LdapAuthenticatable)

View File

@@ -0,0 +1,61 @@
require "omniauth_openid_connect"
module OmniAuth
module Strategies
class DynamicOIDC < OmniAuth::Strategies::OpenIDConnect
option :name, "oidc"
option :issuer, "placeholder" # Will be overridden
# Run before request_phase to load config from database
def request_phase
load_configuration_from_database
super
end
# Run before callback_phase to load config from database
def callback_phase
load_configuration_from_database
super
end
private
def load_configuration_from_database
sso_provider_id = request.params["sso_provider_id"] || session["sso_provider_id"]
return unless sso_provider_id.present?
begin
sso_provider = SSOProvider.find_by(id: sso_provider_id, enabled: true)
if sso_provider&.oidc? && sso_provider.configuration
oidc_config = sso_provider.configuration
# Override the options with database values
options[:issuer] = oidc_config.issuer
options[:client_options] = {
identifier: oidc_config.client_id,
secret: oidc_config.client_secret,
redirect_uri: callback_url,
authorization_endpoint: oidc_config.authorization_endpoint.presence,
token_endpoint: oidc_config.token_endpoint.presence,
userinfo_endpoint: oidc_config.userinfo_endpoint.presence,
jwks_uri: oidc_config.jwks_uri.presence
}.compact
options[:scope] = oidc_config.scopes&.split(" ") || [ :openid, :email, :profile ]
options[:response_type] = :code
options[:uid_field] = "sub"
# Store in session for callback
session["sso_provider_id"] = sso_provider.id
session["sso_account_id"] = sso_provider.account_id
end
rescue ActiveRecord::StatementInvalid, PG::UndefinedTable
# Database not ready yet or table doesn't exist
Rails.logger.debug "DynamicOIDC: database not ready"
end
end
end
end
end

View File

@@ -0,0 +1,14 @@
FactoryBot.define do
factory :ldap_configuration do
host { "MyString" }
port { 1 }
encryption { "MyString" }
base_dn { "MyString" }
bind_dn { "MyString" }
bind_password { "MyString" }
uid_attribute { "MyString" }
email_attribute { "MyString" }
name_attribute { "MyString" }
filter { "MyString" }
end
end

View File

@@ -0,0 +1,28 @@
# == Schema Information
#
# Table name: oidc_configurations
#
# id :bigint not null, primary key
# authorization_endpoint :string
# client_secret :string not null
# issuer :string not null
# jwks_uri :string
# scopes :string default("openid email profile")
# token_endpoint :string
# userinfo_endpoint :string
# created_at :datetime not null
# updated_at :datetime not null
# client_id :string not null
#
FactoryBot.define do
factory :oidc_configuration do
issuer { "MyString" }
client_id { "MyString" }
client_secret { "MyString" }
authorization_endpoint { "MyString" }
token_endpoint { "MyString" }
userinfo_endpoint { "MyString" }
jwks_uri { "MyString" }
scopes { "MyString" }
end
end

View File

@@ -0,0 +1,30 @@
# == Schema Information
#
# Table name: sso_providers
#
# id :bigint not null, primary key
# configuration_type :string not null
# enabled :boolean default(TRUE), not null
# name :string not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# configuration_id :bigint not null
#
# Indexes
#
# index_sso_providers_on_account_id (account_id) UNIQUE
# index_sso_providers_on_configuration (configuration_type,configuration_id)
#
# Foreign Keys
#
# fk_rails_... (account_id => accounts.id)
#
FactoryBot.define do
factory :sso_provider do
account { nil }
configuration { nil }
name { "MyString" }
enabled { false }
end
end

View File

@@ -0,0 +1,5 @@
require 'rails_helper'
RSpec.describe LdapConfiguration, type: :model do
pending "add some examples to (or delete) #{__FILE__}"
end

View File

@@ -0,0 +1,21 @@
# == Schema Information
#
# Table name: oidc_configurations
#
# id :bigint not null, primary key
# authorization_endpoint :string
# client_secret :string not null
# issuer :string not null
# jwks_uri :string
# scopes :string default("openid email profile")
# token_endpoint :string
# userinfo_endpoint :string
# created_at :datetime not null
# updated_at :datetime not null
# client_id :string not null
#
require 'rails_helper'
RSpec.describe OIDCConfiguration, type: :model do
pending "add some examples to (or delete) #{__FILE__}"
end

View File

@@ -18,6 +18,14 @@
# updated_at :datetime not null
# project_id :bigint not null
#
# Indexes
#
# index_services_on_project_id (project_id)
#
# Foreign Keys
#
# fk_rails_... (project_id => projects.id)
#
require 'rails_helper'
RSpec.describe Service, type: :model do

View File

@@ -0,0 +1,27 @@
# == Schema Information
#
# Table name: sso_providers
#
# id :bigint not null, primary key
# configuration_type :string not null
# enabled :boolean default(TRUE), not null
# name :string not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# configuration_id :bigint not null
#
# Indexes
#
# index_sso_providers_on_account_id (account_id) UNIQUE
# index_sso_providers_on_configuration (configuration_type,configuration_id)
#
# Foreign Keys
#
# fk_rails_... (account_id => accounts.id)
#
require 'rails_helper'
RSpec.describe SSOProvider, type: :model do
pending "add some examples to (or delete) #{__FILE__}"
end