mirror of
https://github.com/czhu12/canine.git
synced 2025-12-16 16:35:10 -06:00
Merge pull request #456 from CanineHQ/chriszhu__oidc
added oidc impementation
This commit is contained in:
2
Gemfile
2
Gemfile
@@ -116,3 +116,5 @@ gem 'flipper-active_record', '~> 1.2.2'
|
|||||||
gem 'flipper-ui', '~> 1.2.2'
|
gem 'flipper-ui', '~> 1.2.2'
|
||||||
|
|
||||||
gem "net-ldap", "~> 0.20.0"
|
gem "net-ldap", "~> 0.20.0"
|
||||||
|
|
||||||
|
gem "jwt", "~> 2.9"
|
||||||
|
|||||||
@@ -723,6 +723,7 @@ DEPENDENCIES
|
|||||||
importmap-rails
|
importmap-rails
|
||||||
jbuilder
|
jbuilder
|
||||||
jsbundling-rails
|
jsbundling-rails
|
||||||
|
jwt (~> 2.9)
|
||||||
k8s-ruby (~> 0.17.2)
|
k8s-ruby (~> 0.17.2)
|
||||||
kubeclient (~> 4.12)
|
kubeclient (~> 4.12)
|
||||||
light-service (~> 0.20.0)
|
light-service (~> 0.20.0)
|
||||||
|
|||||||
@@ -3,20 +3,43 @@
|
|||||||
module SSO
|
module SSO
|
||||||
class CreateUserInAccount
|
class CreateUserInAccount
|
||||||
extend LightService::Action
|
extend LightService::Action
|
||||||
expects :email, :account
|
expects :email, :account, :sso_provider, :uid
|
||||||
|
expects :name, default: nil
|
||||||
promises :user
|
promises :user
|
||||||
|
|
||||||
executed do |context|
|
executed do |context|
|
||||||
user = User.find_or_initialize_by(email: context.email.downcase)
|
# First try to find user by SSO provider identity
|
||||||
|
provider = Provider.find_by(sso_provider: context.sso_provider, uid: context.uid)
|
||||||
|
|
||||||
if user.new_record?
|
if provider
|
||||||
password = SecureRandom.hex(32)
|
user = provider.user
|
||||||
user.password = password
|
else
|
||||||
user.password_confirmation = password
|
# Fall back to finding by email, or create new user
|
||||||
|
user = User.find_or_initialize_by(email: context.email.downcase)
|
||||||
|
|
||||||
unless user.save
|
if user.new_record?
|
||||||
context.fail_and_return!("Failed to create user", errors: user.errors)
|
password = SecureRandom.hex(32)
|
||||||
|
user.password = password
|
||||||
|
user.password_confirmation = password
|
||||||
|
|
||||||
|
unless user.save
|
||||||
|
context.fail_and_return!("Failed to create user", errors: user.errors)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Create provider record to link user to SSO provider
|
||||||
|
Provider.create!(
|
||||||
|
user: user,
|
||||||
|
sso_provider: context.sso_provider,
|
||||||
|
uid: context.uid,
|
||||||
|
provider: context.sso_provider.name
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Update name from SSO provider on every login
|
||||||
|
if context.name.present?
|
||||||
|
name_parts = context.name.split(" ", 2)
|
||||||
|
user.update(first_name: name_parts.first, last_name: name_parts.second)
|
||||||
end
|
end
|
||||||
|
|
||||||
AccountUser.find_or_create_by!(account: context.account, user: user)
|
AccountUser.find_or_create_by!(account: context.account, user: user)
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
class SSO::SyncUserTeams
|
class SSO::SyncUserTeams
|
||||||
extend LightService::Organizer
|
extend LightService::Organizer
|
||||||
def self.call(email, team_names, account)
|
def self.call(email:, team_names:, account:, sso_provider:, uid:, name: nil, create_teams: false)
|
||||||
with(email:, team_names:, account:).reduce(
|
actions = []
|
||||||
SSO::CreateTeamsInAccount,
|
actions << SSO::CreateTeamsInAccount if create_teams
|
||||||
SSO::CreateUserInAccount,
|
actions << SSO::CreateUserInAccount
|
||||||
SSO::SyncTeams,
|
actions << SSO::SyncTeams if create_teams
|
||||||
)
|
|
||||||
|
with(email:, team_names:, account:, sso_provider:, uid:, name:).reduce(actions)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -7,7 +7,14 @@ module SSOProviders
|
|||||||
promises :configuration
|
promises :configuration
|
||||||
|
|
||||||
executed do |context|
|
executed do |context|
|
||||||
context.configuration = LDAPConfiguration.new(context.configuration_params)
|
context.configuration = case context.provider_type
|
||||||
|
when "ldap"
|
||||||
|
LDAPConfiguration.new(context.configuration_params)
|
||||||
|
when "oidc"
|
||||||
|
OIDCConfiguration.new(context.configuration_params)
|
||||||
|
else
|
||||||
|
context.fail_and_return!("Unknown provider type: #{context.provider_type}")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
20
app/avo/resources/oidc_configuration.rb
Normal file
20
app/avo/resources/oidc_configuration.rb
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
class Avo::Resources::OIDCConfiguration < Avo::BaseResource
|
||||||
|
self.includes = []
|
||||||
|
|
||||||
|
def fields
|
||||||
|
field :id, as: :id
|
||||||
|
field :issuer, as: :text, required: true, help: "OIDC provider issuer URL (e.g., https://auth.example.com)"
|
||||||
|
field :client_id, as: :text, required: true, help: "OAuth 2.0 client ID"
|
||||||
|
field :client_secret, as: :password, required: true, help: "OAuth 2.0 client secret"
|
||||||
|
field :authorization_endpoint, as: :text, help: "Authorization endpoint URL (leave blank to use discovery)"
|
||||||
|
field :token_endpoint, as: :text, help: "Token endpoint URL (leave blank to use discovery)"
|
||||||
|
field :userinfo_endpoint, as: :text, help: "UserInfo endpoint URL (leave blank to use discovery)"
|
||||||
|
field :jwks_uri, as: :text, help: "JWKS URI for token verification (leave blank to use discovery)"
|
||||||
|
field :scopes, as: :text, help: "Space-separated scopes to request", default: "openid email profile"
|
||||||
|
field :uid_claim, as: :text, required: true, help: "Claim to use as user identifier", default: "sub"
|
||||||
|
field :email_claim, as: :text, help: "Claim for email address", default: "email"
|
||||||
|
field :name_claim, as: :text, help: "Claim for full name", default: "name"
|
||||||
|
|
||||||
|
field :sso_provider, as: :has_one
|
||||||
|
end
|
||||||
|
end
|
||||||
110
app/controllers/accounts/oidc_controller.rb
Normal file
110
app/controllers/accounts/oidc_controller.rb
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
module Accounts
|
||||||
|
class OIDCController < ApplicationController
|
||||||
|
skip_before_action :authenticate_user!
|
||||||
|
before_action :load_account
|
||||||
|
|
||||||
|
def authorize
|
||||||
|
oidc_configuration = @account.sso_provider&.configuration
|
||||||
|
unless oidc_configuration.is_a?(OIDCConfiguration)
|
||||||
|
redirect_to account_sign_in_path(@account.slug), alert: "OIDC is not configured for this account"
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Store state in session for CSRF protection
|
||||||
|
state = SecureRandom.hex(32)
|
||||||
|
session[:oidc_state] = state
|
||||||
|
|
||||||
|
# Build authorization URL
|
||||||
|
authorization_url = build_authorization_url(oidc_configuration, state)
|
||||||
|
redirect_to authorization_url, allow_other_host: true
|
||||||
|
end
|
||||||
|
|
||||||
|
def callback
|
||||||
|
# Verify state for CSRF protection
|
||||||
|
unless params[:state].present? && params[:state] == session[:oidc_state]
|
||||||
|
redirect_to root_path, alert: "Invalid state parameter"
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
oidc_configuration = @account.sso_provider&.configuration
|
||||||
|
|
||||||
|
unless oidc_configuration.is_a?(OIDCConfiguration)
|
||||||
|
redirect_to root_path, alert: "OIDC is not configured"
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if params[:error].present?
|
||||||
|
redirect_to account_sign_in_path(@account.slug), alert: "Authentication failed: #{params[:error_description] || params[:error]}"
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Exchange code for tokens
|
||||||
|
result = OIDC::Authenticator.new(oidc_configuration).authenticate(
|
||||||
|
code: params[:code],
|
||||||
|
redirect_uri: oidc_callback_url(slug: @account.slug)
|
||||||
|
)
|
||||||
|
|
||||||
|
unless result.success?
|
||||||
|
redirect_to account_sign_in_path(@account.slug), alert: result.error_message
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Create or find user
|
||||||
|
sso_provider = @account.sso_provider
|
||||||
|
ar_result = SSO::SyncUserTeams.call(
|
||||||
|
email: result.email,
|
||||||
|
team_names: result.groups || [],
|
||||||
|
account: @account,
|
||||||
|
sso_provider: sso_provider,
|
||||||
|
uid: result.uid,
|
||||||
|
name: result.name,
|
||||||
|
create_teams: sso_provider.just_in_time_team_provisioning_mode?
|
||||||
|
)
|
||||||
|
|
||||||
|
if ar_result.failure?
|
||||||
|
redirect_to account_sign_in_path(@account.slug), alert: "Failed to create user account"
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Clear session state
|
||||||
|
session.delete(:oidc_state)
|
||||||
|
|
||||||
|
# Sign in user
|
||||||
|
sign_in(ar_result.user)
|
||||||
|
session[:account_id] = @account.id
|
||||||
|
redirect_to after_sign_in_path_for(ar_result.user), notice: "Signed in successfully"
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def load_account
|
||||||
|
@account = Account.friendly.find(params[:slug])
|
||||||
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
redirect_to root_path, alert: "Account not found"
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_authorization_url(oidc_configuration, state)
|
||||||
|
params = {
|
||||||
|
response_type: "code",
|
||||||
|
client_id: oidc_configuration.client_id,
|
||||||
|
redirect_uri: oidc_callback_url(slug: @account.slug),
|
||||||
|
scope: oidc_configuration.scopes,
|
||||||
|
state: state
|
||||||
|
}
|
||||||
|
|
||||||
|
auth_endpoint = oidc_configuration.authorization_endpoint.presence || discover_authorization_endpoint(oidc_configuration)
|
||||||
|
"#{auth_endpoint}?#{params.to_query}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def discover_authorization_endpoint(oidc_configuration)
|
||||||
|
# Fetch from OIDC discovery document
|
||||||
|
discovery_url = oidc_configuration.discovery_url
|
||||||
|
response = HTTP.get(discovery_url)
|
||||||
|
if response.status.success?
|
||||||
|
JSON.parse(response.body.to_s)["authorization_endpoint"]
|
||||||
|
else
|
||||||
|
raise "Failed to discover OIDC endpoints"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -6,23 +6,29 @@ module Accounts
|
|||||||
end
|
end
|
||||||
|
|
||||||
def new
|
def new
|
||||||
|
@provider_type = params[:provider_type] || "ldap"
|
||||||
@sso_provider = current_account.build_sso_provider
|
@sso_provider = current_account.build_sso_provider
|
||||||
@ldap_configuration = LDAPConfiguration.new
|
@ldap_configuration = LDAPConfiguration.new
|
||||||
|
@oidc_configuration = OIDCConfiguration.new
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
|
provider_type = params[:provider_type] || "ldap"
|
||||||
|
|
||||||
result = SSOProviders::Create.call(
|
result = SSOProviders::Create.call(
|
||||||
account: current_account,
|
account: current_account,
|
||||||
sso_provider_params: sso_provider_params,
|
sso_provider_params: sso_provider_params,
|
||||||
configuration_params: ldap_configuration_params,
|
configuration_params: configuration_params_for(provider_type),
|
||||||
provider_type: "ldap"
|
provider_type: provider_type
|
||||||
)
|
)
|
||||||
|
|
||||||
if result.success?
|
if result.success?
|
||||||
redirect_to sso_provider_path, notice: "SSO provider created successfully"
|
redirect_to sso_provider_path, notice: "SSO provider created successfully"
|
||||||
else
|
else
|
||||||
|
@provider_type = provider_type
|
||||||
@sso_provider = result.sso_provider
|
@sso_provider = result.sso_provider
|
||||||
@ldap_configuration = result.configuration
|
@ldap_configuration = provider_type == "ldap" ? result.configuration : LDAPConfiguration.new
|
||||||
|
@oidc_configuration = provider_type == "oidc" ? result.configuration : OIDCConfiguration.new
|
||||||
render :new, status: :unprocessable_entity
|
render :new, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -30,22 +36,27 @@ module Accounts
|
|||||||
def edit
|
def edit
|
||||||
@sso_provider = current_account.sso_provider
|
@sso_provider = current_account.sso_provider
|
||||||
redirect_to new_sso_provider_path, alert: "No SSO provider configured" unless @sso_provider
|
redirect_to new_sso_provider_path, alert: "No SSO provider configured" unless @sso_provider
|
||||||
@ldap_configuration = @sso_provider&.configuration
|
@provider_type = @sso_provider&.oidc? ? "oidc" : "ldap"
|
||||||
|
@ldap_configuration = @sso_provider&.ldap? ? @sso_provider.configuration : LDAPConfiguration.new
|
||||||
|
@oidc_configuration = @sso_provider&.oidc? ? @sso_provider.configuration : OIDCConfiguration.new
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
@sso_provider = current_account.sso_provider
|
@sso_provider = current_account.sso_provider
|
||||||
|
provider_type = @sso_provider.oidc? ? "oidc" : "ldap"
|
||||||
|
|
||||||
result = SSOProviders::Update.call(
|
result = SSOProviders::Update.call(
|
||||||
sso_provider: @sso_provider,
|
sso_provider: @sso_provider,
|
||||||
sso_provider_params: sso_provider_params,
|
sso_provider_params: sso_provider_params,
|
||||||
configuration_params: ldap_configuration_params
|
configuration_params: configuration_params_for(provider_type)
|
||||||
)
|
)
|
||||||
|
|
||||||
if result.success?
|
if result.success?
|
||||||
redirect_to sso_provider_path, notice: "SSO provider updated successfully"
|
redirect_to sso_provider_path, notice: "SSO provider updated successfully"
|
||||||
else
|
else
|
||||||
@ldap_configuration = @sso_provider.configuration
|
@provider_type = provider_type
|
||||||
|
@ldap_configuration = @sso_provider.ldap? ? @sso_provider.configuration : LDAPConfiguration.new
|
||||||
|
@oidc_configuration = @sso_provider.oidc? ? @sso_provider.configuration : OIDCConfiguration.new
|
||||||
render :edit, status: :unprocessable_entity
|
render :edit, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -84,6 +95,17 @@ module Accounts
|
|||||||
params.require(:sso_provider).permit(:name, :enabled, :team_provisioning_mode)
|
params.require(:sso_provider).permit(:name, :enabled, :team_provisioning_mode)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def configuration_params_for(provider_type)
|
||||||
|
case provider_type
|
||||||
|
when "ldap"
|
||||||
|
ldap_configuration_params
|
||||||
|
when "oidc"
|
||||||
|
oidc_configuration_params
|
||||||
|
else
|
||||||
|
{}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def ldap_configuration_params
|
def ldap_configuration_params
|
||||||
params.require(:ldap_configuration).permit(
|
params.require(:ldap_configuration).permit(
|
||||||
:host,
|
:host,
|
||||||
@@ -98,5 +120,21 @@ module Accounts
|
|||||||
:encryption
|
:encryption
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def oidc_configuration_params
|
||||||
|
params.require(:oidc_configuration).permit(
|
||||||
|
:issuer,
|
||||||
|
:client_id,
|
||||||
|
:client_secret,
|
||||||
|
:authorization_endpoint,
|
||||||
|
:token_endpoint,
|
||||||
|
:userinfo_endpoint,
|
||||||
|
:jwks_uri,
|
||||||
|
:scopes,
|
||||||
|
:uid_claim,
|
||||||
|
:email_claim,
|
||||||
|
:name_claim
|
||||||
|
)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
4
app/controllers/avo/oidc_configurations_controller.rb
Normal file
4
app/controllers/avo/oidc_configurations_controller.rb
Normal 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
|
||||||
@@ -36,8 +36,11 @@ class Users::SessionsController < Devise::SessionsController
|
|||||||
self.resource = resource_class.new(sign_in_params)
|
self.resource = resource_class.new(sign_in_params)
|
||||||
clean_up_passwords(resource)
|
clean_up_passwords(resource)
|
||||||
@sso_provider = @account.sso_provider if @account.sso_enabled?
|
@sso_provider = @account.sso_provider if @account.sso_enabled?
|
||||||
|
|
||||||
if @account.sso_provider&.ldap?
|
if @account.sso_provider&.ldap?
|
||||||
render "devise/sessions/ldap"
|
render "devise/sessions/ldap"
|
||||||
|
elsif @account.sso_provider&.oidc?
|
||||||
|
render "devise/sessions/oidc"
|
||||||
else
|
else
|
||||||
render :new
|
render :new
|
||||||
end
|
end
|
||||||
@@ -59,6 +62,9 @@ class Users::SessionsController < Devise::SessionsController
|
|||||||
clean_up_passwords(self.resource)
|
clean_up_passwords(self.resource)
|
||||||
render "devise/sessions/ldap"
|
render "devise/sessions/ldap"
|
||||||
end
|
end
|
||||||
|
elsif @account.sso_provider&.oidc?
|
||||||
|
# OIDC uses a redirect flow, so this shouldn't be called directly
|
||||||
|
redirect_to account_sign_in_path(@account.slug)
|
||||||
else
|
else
|
||||||
redirect_to new_user_session_path
|
redirect_to new_user_session_path
|
||||||
end
|
end
|
||||||
|
|||||||
36
app/models/oidc_configuration.rb
Normal file
36
app/models/oidc_configuration.rb
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# == Schema Information
|
||||||
|
#
|
||||||
|
# Table name: oidc_configurations
|
||||||
|
#
|
||||||
|
# id :bigint not null, primary key
|
||||||
|
# issuer :string not null
|
||||||
|
# client_id :string not null
|
||||||
|
# client_secret :string not null
|
||||||
|
# authorization_endpoint :string
|
||||||
|
# token_endpoint :string
|
||||||
|
# userinfo_endpoint :string
|
||||||
|
# jwks_uri :string
|
||||||
|
# scopes :string default("openid email profile")
|
||||||
|
# uid_claim :string default("sub"), not null
|
||||||
|
# email_claim :string default("email")
|
||||||
|
# name_claim :string default("name")
|
||||||
|
# created_at :datetime not null
|
||||||
|
# updated_at :datetime not null
|
||||||
|
#
|
||||||
|
class OIDCConfiguration < ApplicationRecord
|
||||||
|
has_one :sso_provider, as: :configuration, dependent: :destroy
|
||||||
|
has_one :account, through: :sso_provider
|
||||||
|
|
||||||
|
validates :issuer, presence: true
|
||||||
|
validates :client_id, presence: true
|
||||||
|
validates :client_secret, presence: true
|
||||||
|
validates :uid_claim, presence: true
|
||||||
|
|
||||||
|
def discovery_url
|
||||||
|
URI.join(issuer, ".well-known/openid-configuration").to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
def uses_discovery?
|
||||||
|
authorization_endpoint.blank? && token_endpoint.blank?
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -15,14 +15,18 @@
|
|||||||
# created_at :datetime not null
|
# created_at :datetime not null
|
||||||
# updated_at :datetime not null
|
# updated_at :datetime not null
|
||||||
# external_id :string
|
# external_id :string
|
||||||
|
# sso_provider_id :bigint
|
||||||
# user_id :bigint not null
|
# user_id :bigint not null
|
||||||
#
|
#
|
||||||
# Indexes
|
# Indexes
|
||||||
#
|
#
|
||||||
# index_providers_on_user_id (user_id)
|
# index_providers_on_sso_provider_id (sso_provider_id)
|
||||||
|
# index_providers_on_sso_provider_id_and_uid (sso_provider_id,uid) UNIQUE WHERE (sso_provider_id IS NOT NULL)
|
||||||
|
# index_providers_on_user_id (user_id)
|
||||||
#
|
#
|
||||||
# Foreign Keys
|
# Foreign Keys
|
||||||
#
|
#
|
||||||
|
# fk_rails_... (sso_provider_id => sso_providers.id)
|
||||||
# fk_rails_... (user_id => users.id)
|
# fk_rails_... (user_id => users.id)
|
||||||
#
|
#
|
||||||
class Provider < ApplicationRecord
|
class Provider < ApplicationRecord
|
||||||
@@ -44,8 +48,10 @@ class Provider < ApplicationRecord
|
|||||||
AVAILABLE_PROVIDERS = [ GITHUB_PROVIDER, GITLAB_PROVIDER, CUSTOM_REGISTRY_PROVIDER ].freeze
|
AVAILABLE_PROVIDERS = [ GITHUB_PROVIDER, GITLAB_PROVIDER, CUSTOM_REGISTRY_PROVIDER ].freeze
|
||||||
validates :registry_url, presence: true, if: :container_registry?
|
validates :registry_url, presence: true, if: :container_registry?
|
||||||
scope :has_container_registry, -> { where(provider: [ GITHUB_PROVIDER, GITLAB_PROVIDER, CUSTOM_REGISTRY_PROVIDER ]) }
|
scope :has_container_registry, -> { where(provider: [ GITHUB_PROVIDER, GITLAB_PROVIDER, CUSTOM_REGISTRY_PROVIDER ]) }
|
||||||
|
scope :non_sso, -> { where(sso_provider_id: nil) }
|
||||||
|
|
||||||
belongs_to :user
|
belongs_to :user
|
||||||
|
belongs_to :sso_provider, class_name: "SSOProvider", optional: true
|
||||||
|
|
||||||
Devise.omniauth_configs.keys.each do |provider|
|
Devise.omniauth_configs.keys.each do |provider|
|
||||||
scope provider, -> { where(provider: provider) }
|
scope provider, -> { where(provider: provider) }
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
class SSOProvider < ApplicationRecord
|
class SSOProvider < ApplicationRecord
|
||||||
belongs_to :account
|
belongs_to :account
|
||||||
belongs_to :configuration, polymorphic: true, dependent: :destroy
|
belongs_to :configuration, polymorphic: true, dependent: :destroy
|
||||||
|
has_many :providers, dependent: :nullify
|
||||||
|
|
||||||
validates :account_id, uniqueness: true
|
validates :account_id, uniqueness: true
|
||||||
|
|
||||||
@@ -36,4 +37,12 @@ class SSOProvider < ApplicationRecord
|
|||||||
def ldap?
|
def ldap?
|
||||||
configuration_type == "LDAPConfiguration"
|
configuration_type == "LDAPConfiguration"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def oidc?
|
||||||
|
configuration_type == "OIDCConfiguration"
|
||||||
|
end
|
||||||
|
|
||||||
|
def sso_users_count
|
||||||
|
providers.joins(:user).distinct.count(:user_id)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
163
app/services/oidc/authenticator.rb
Normal file
163
app/services/oidc/authenticator.rb
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
# app/services/oidc/authenticator.rb
|
||||||
|
module OIDC
|
||||||
|
class Authenticator
|
||||||
|
Result = Struct.new(
|
||||||
|
:success?,
|
||||||
|
:email,
|
||||||
|
:name,
|
||||||
|
:uid,
|
||||||
|
:groups,
|
||||||
|
:id_token,
|
||||||
|
:access_token,
|
||||||
|
:error_message,
|
||||||
|
keyword_init: true
|
||||||
|
)
|
||||||
|
|
||||||
|
def initialize(oidc_configuration, logger: Rails.logger)
|
||||||
|
@config = oidc_configuration
|
||||||
|
@logger = logger
|
||||||
|
@discovery_cache = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def authenticate(code:, redirect_uri:)
|
||||||
|
# Exchange authorization code for tokens
|
||||||
|
token_result = exchange_code_for_tokens(code, redirect_uri)
|
||||||
|
return token_result if token_result.failure?
|
||||||
|
|
||||||
|
# Parse and validate ID token claims
|
||||||
|
claims = extract_claims(token_result.id_token, token_result.access_token)
|
||||||
|
return claims if claims.is_a?(Result) && claims.failure?
|
||||||
|
|
||||||
|
email = claims[config.email_claim] || claims["email"]
|
||||||
|
name = claims[config.name_claim] || claims["name"]
|
||||||
|
uid = claims[config.uid_claim] || claims["sub"]
|
||||||
|
|
||||||
|
if email.blank?
|
||||||
|
return Result.new(success?: false, error_message: "Email claim not found in token")
|
||||||
|
end
|
||||||
|
|
||||||
|
Result.new(
|
||||||
|
success?: true,
|
||||||
|
email: email,
|
||||||
|
name: name,
|
||||||
|
uid: uid,
|
||||||
|
groups: extract_groups(claims),
|
||||||
|
id_token: token_result.id_token,
|
||||||
|
access_token: token_result.access_token,
|
||||||
|
error_message: nil
|
||||||
|
)
|
||||||
|
rescue => e
|
||||||
|
@logger.error "OIDC auth: unexpected error - #{e.class}: #{e.message}"
|
||||||
|
Result.new(success?: false, error_message: e.message)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
attr_reader :config, :logger
|
||||||
|
|
||||||
|
def exchange_code_for_tokens(code, redirect_uri)
|
||||||
|
token_endpoint = config.token_endpoint.presence || discover_endpoint("token_endpoint")
|
||||||
|
|
||||||
|
response = HTTP.post(token_endpoint, form: {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: code,
|
||||||
|
redirect_uri: redirect_uri,
|
||||||
|
client_id: config.client_id,
|
||||||
|
client_secret: config.client_secret
|
||||||
|
})
|
||||||
|
|
||||||
|
unless response.status.success?
|
||||||
|
error_body = JSON.parse(response.body.to_s) rescue {}
|
||||||
|
error_msg = error_body["error_description"] || error_body["error"] || "Token exchange failed"
|
||||||
|
return Result.new(success?: false, error_message: error_msg)
|
||||||
|
end
|
||||||
|
|
||||||
|
token_data = JSON.parse(response.body.to_s)
|
||||||
|
|
||||||
|
OpenStruct.new(
|
||||||
|
success?: true,
|
||||||
|
id_token: token_data["id_token"],
|
||||||
|
access_token: token_data["access_token"],
|
||||||
|
refresh_token: token_data["refresh_token"]
|
||||||
|
)
|
||||||
|
rescue HTTP::Error => e
|
||||||
|
Result.new(success?: false, error_message: "Failed to exchange code: #{e.message}")
|
||||||
|
end
|
||||||
|
|
||||||
|
def extract_claims(id_token, access_token)
|
||||||
|
if id_token.present?
|
||||||
|
payload = decode_jwt(id_token)
|
||||||
|
return payload if payload.is_a?(Result)
|
||||||
|
payload
|
||||||
|
elsif access_token.present?
|
||||||
|
# Fallback to userinfo endpoint
|
||||||
|
fetch_userinfo(access_token)
|
||||||
|
else
|
||||||
|
Result.new(success?: false, error_message: "No tokens received")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def decode_jwt(token)
|
||||||
|
jwks = fetch_jwks
|
||||||
|
decoded = JWT.decode(token, nil, true, {
|
||||||
|
algorithms: %w[RS256 RS384 RS512 ES256 ES384 ES512],
|
||||||
|
jwks: jwks,
|
||||||
|
iss: config.issuer,
|
||||||
|
aud: config.client_id,
|
||||||
|
verify_iss: true,
|
||||||
|
verify_aud: true
|
||||||
|
})
|
||||||
|
decoded.first
|
||||||
|
rescue JWT::DecodeError => e
|
||||||
|
@logger.error "OIDC auth: JWT decode error - #{e.message}"
|
||||||
|
Result.new(success?: false, error_message: "Invalid token: #{e.message}")
|
||||||
|
end
|
||||||
|
|
||||||
|
def fetch_jwks
|
||||||
|
return @jwks_cache if @jwks_cache
|
||||||
|
|
||||||
|
jwks_uri = config.jwks_uri.presence || discover_endpoint("jwks_uri")
|
||||||
|
response = HTTP.get(jwks_uri)
|
||||||
|
|
||||||
|
unless response.status.success?
|
||||||
|
raise JWT::DecodeError, "Failed to fetch JWKS from #{jwks_uri}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@jwks_cache = JWT::JWK::Set.new(JSON.parse(response.body.to_s))
|
||||||
|
end
|
||||||
|
|
||||||
|
def fetch_userinfo(access_token)
|
||||||
|
userinfo_endpoint = config.userinfo_endpoint.presence || discover_endpoint("userinfo_endpoint")
|
||||||
|
|
||||||
|
response = HTTP.auth("Bearer #{access_token}").get(userinfo_endpoint)
|
||||||
|
|
||||||
|
unless response.status.success?
|
||||||
|
return Result.new(success?: false, error_message: "Failed to fetch user info")
|
||||||
|
end
|
||||||
|
|
||||||
|
JSON.parse(response.body.to_s)
|
||||||
|
rescue => e
|
||||||
|
Result.new(success?: false, error_message: "Failed to fetch user info: #{e.message}")
|
||||||
|
end
|
||||||
|
|
||||||
|
def extract_groups(claims)
|
||||||
|
# Common group claims from various OIDC providers
|
||||||
|
groups = claims["groups"] || claims["roles"] || claims["cognito:groups"] || []
|
||||||
|
groups = [ groups ] unless groups.is_a?(Array)
|
||||||
|
groups.map { |g| { name: g.to_s } }
|
||||||
|
end
|
||||||
|
|
||||||
|
def discover_endpoint(endpoint_name)
|
||||||
|
discovery_doc[endpoint_name]
|
||||||
|
end
|
||||||
|
|
||||||
|
def discovery_doc
|
||||||
|
return @discovery_cache if @discovery_cache
|
||||||
|
|
||||||
|
response = HTTP.get(config.discovery_url)
|
||||||
|
raise "Failed to fetch OIDC discovery document" unless response.status.success?
|
||||||
|
|
||||||
|
@discovery_cache = JSON.parse(response.body.to_s)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -5,42 +5,49 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-sm text-gray-500 mt-2 mb-4">
|
<div class="text-sm text-gray-500 mt-2 mb-4">
|
||||||
Update your LDAP provider configuration.
|
<% if @provider_type == "oidc" %>
|
||||||
|
Update your OIDC provider configuration.
|
||||||
|
<% else %>
|
||||||
|
Update your LDAP provider configuration.
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= form_with model: @sso_provider, url: sso_provider_path do |form| %>
|
<%= form_with model: @sso_provider, url: sso_provider_path do |form| %>
|
||||||
<%= render "shared/error_messages", resource: form.object %>
|
<%= render "shared/error_messages", resource: form.object %>
|
||||||
|
|
||||||
<div class="form-control mt-4 w-full max-w-sm">
|
<div class="space-y-8">
|
||||||
<label class="label">
|
<%= render(FormFieldComponent.new(
|
||||||
<span class="label-text">Provider Name</span>
|
label: "Provider Name",
|
||||||
</label>
|
description: "A friendly name for this SSO provider"
|
||||||
<%= form.text_field :name, class: "input input-bordered", required: true, placeholder: "e.g., Company SSO" %>
|
)) do %>
|
||||||
<label class="label">
|
<%= form.text_field :name, class: "input input-bordered w-full", required: true, placeholder: "e.g., Company SSO" %>
|
||||||
<span class="label-text-alt">A friendly name for this SSO provider</span>
|
<label class="label">
|
||||||
</label>
|
<span class="label-text-alt">* Required</span>
|
||||||
</div>
|
</label>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<div class="form-control mt-1 w-full max-w-sm">
|
<%= render(FormFieldComponent.new(
|
||||||
<label class="label cursor-pointer justify-start gap-2">
|
label: "Enabled",
|
||||||
|
description: "Enable or disable this SSO provider"
|
||||||
|
)) do %>
|
||||||
<%= form.check_box :enabled, class: "checkbox checkbox-primary" %>
|
<%= form.check_box :enabled, class: "checkbox checkbox-primary" %>
|
||||||
<span class="label-text">Enable this provider</span>
|
<% end %>
|
||||||
</label>
|
|
||||||
|
<%= render(FormFieldComponent.new(
|
||||||
|
label: "Team Provisioning Mode",
|
||||||
|
description: "How teams are provisioned for SSO users"
|
||||||
|
)) do %>
|
||||||
|
<%= form.select :team_provisioning_mode, SSOProvider.team_provisioning_modes.keys.map { |k| [k.titleize, k] }, {}, class: "select select-bordered w-full" %>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-control mt-4 w-full max-w-sm">
|
<% if @provider_type == "oidc" %>
|
||||||
<label class="label">
|
<div class="divider">OIDC Configuration</div>
|
||||||
<span class="label-text">Team Provisioning Mode</span>
|
<%= render "accounts/sso_providers/oidc/form_fields", oidc_configuration: @oidc_configuration %>
|
||||||
</label>
|
<% else %>
|
||||||
<%= form.select :team_provisioning_mode, SSOProvider.team_provisioning_modes.keys.map { |k| [k.titleize, k] }, {}, class: "select select-bordered" %>
|
<div class="divider">LDAP Configuration</div>
|
||||||
<label class="label">
|
<%= render "accounts/sso_providers/ldap/form_fields", ldap_configuration: @ldap_configuration %>
|
||||||
<span class="label-text-alt">How teams are provisioned for SSO users</span>
|
<% end %>
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="divider">LDAP Configuration</div>
|
|
||||||
|
|
||||||
<%= render "accounts/sso_providers/ldap/form_fields", ldap_configuration: @ldap_configuration %>
|
|
||||||
|
|
||||||
<div class="form-footer mt-6">
|
<div class="form-footer mt-6">
|
||||||
<%= form.submit "Update Provider", class: "btn btn-primary" %>
|
<%= form.submit "Update Provider", class: "btn btn-primary" %>
|
||||||
|
|||||||
@@ -1,114 +1,110 @@
|
|||||||
<%= fields_for :ldap_configuration, ldap_configuration do |ldap_form| %>
|
<%= fields_for :ldap_configuration, ldap_configuration do |ldap_form| %>
|
||||||
<div class="form-control mt-1 w-full max-w-sm">
|
<div class="space-y-8">
|
||||||
<label class="label">
|
<%= render(FormFieldComponent.new(
|
||||||
<span class="label-text">Host <span class="text-error">*</span></span>
|
label: "Host",
|
||||||
</label>
|
description: "LDAP server hostname or IP address"
|
||||||
<%= ldap_form.text_field :host, class: "input input-bordered", required: true, placeholder: "ldap.example.com" %>
|
)) do %>
|
||||||
<label class="label">
|
<%= ldap_form.text_field :host, class: "input input-bordered w-full", required: true, placeholder: "ldap.example.com" %>
|
||||||
<span class="label-text-alt">LDAP server hostname or IP address</span>
|
<label class="label">
|
||||||
</label>
|
<span class="label-text-alt">* Required</span>
|
||||||
|
</label>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= render(FormFieldComponent.new(
|
||||||
|
label: "Port",
|
||||||
|
description: "LDAP server port"
|
||||||
|
)) do %>
|
||||||
|
<%= ldap_form.number_field :port, class: "input input-bordered w-full", required: true, placeholder: "389" %>
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text-alt">Default: 389 (LDAP), 636 (LDAPS)</span>
|
||||||
|
</label>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= render(FormFieldComponent.new(
|
||||||
|
label: "Base DN",
|
||||||
|
description: "Base Distinguished Name for user searches"
|
||||||
|
)) do %>
|
||||||
|
<%= ldap_form.text_field :base_dn, class: "input input-bordered w-full", required: true, placeholder: "dc=example,dc=com" %>
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text-alt">* Required</span>
|
||||||
|
</label>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= render(FormFieldComponent.new(
|
||||||
|
label: "Encryption",
|
||||||
|
description: "Connection encryption method"
|
||||||
|
)) do %>
|
||||||
|
<%= ldap_form.select :encryption,
|
||||||
|
LDAPConfiguration.encryptions.keys.map { |key| [key.titleize, key] },
|
||||||
|
{},
|
||||||
|
class: "select select-bordered w-full" %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= render(FormFieldComponent.new(
|
||||||
|
label: "Bind DN",
|
||||||
|
description: "Service account DN for LDAP searches"
|
||||||
|
)) do %>
|
||||||
|
<%= ldap_form.text_field :bind_dn, class: "input input-bordered w-full", placeholder: "cn=admin,dc=example,dc=com" %>
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text-alt">Leave empty for anonymous bind</span>
|
||||||
|
</label>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= render(FormFieldComponent.new(
|
||||||
|
label: "Bind Password",
|
||||||
|
description: "Password for the bind DN"
|
||||||
|
)) do %>
|
||||||
|
<%= ldap_form.text_field :bind_password, type: "password", class: "input input-bordered w-full" %>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-control mt-1 w-full max-w-sm">
|
<details class="collapse collapse-arrow bg-base-200 mt-8">
|
||||||
<label class="label">
|
|
||||||
<span class="label-text">Port <span class="text-error">*</span></span>
|
|
||||||
</label>
|
|
||||||
<%= ldap_form.number_field :port, class: "input input-bordered", required: true, placeholder: "389" %>
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text-alt">Default: 389 (LDAP), 636 (LDAPS)</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-control mt-1 w-full max-w-sm">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text">Base DN <span class="text-error">*</span></span>
|
|
||||||
</label>
|
|
||||||
<%= ldap_form.text_field :base_dn, class: "input input-bordered", required: true, placeholder: "dc=example,dc=com" %>
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text-alt">Base Distinguished Name for user searches</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-control mt-1 w-full max-w-sm">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text">Encryption</span>
|
|
||||||
</label>
|
|
||||||
<%= ldap_form.select :encryption,
|
|
||||||
LDAPConfiguration.encryptions.keys.map { |key| [key.titleize, key] },
|
|
||||||
{},
|
|
||||||
class: "select select-bordered" %>
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text-alt">Connection encryption method</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="divider">Bind Credentials (Optional)</div>
|
|
||||||
|
|
||||||
<div class="form-control mt-1 w-full max-w-sm">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text">Bind DN</span>
|
|
||||||
</label>
|
|
||||||
<%= ldap_form.text_field :bind_dn, class: "input input-bordered", placeholder: "cn=admin,dc=example,dc=com" %>
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text-alt">Leave empty for anonymous bind</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-control mt-1 w-full max-w-sm">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text">Bind Password</span>
|
|
||||||
</label>
|
|
||||||
<%= ldap_form.password_field :bind_password, class: "input input-bordered" %>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<details class="collapse collapse-arrow bg-base-200 mt-4 max-w-sm">
|
|
||||||
<summary class="collapse-title font-medium">
|
<summary class="collapse-title font-medium">
|
||||||
Advanced Settings (Optional)
|
Advanced Settings (Optional)
|
||||||
</summary>
|
</summary>
|
||||||
<div class="collapse-content space-y-4">
|
<div class="collapse-content">
|
||||||
<div class="form-control mt-1 w-full">
|
<div class="space-y-8 mt-4">
|
||||||
<label class="label">
|
<%= render(FormFieldComponent.new(
|
||||||
<span class="label-text">UID Attribute</span>
|
label: "UID Attribute",
|
||||||
</label>
|
description: "Attribute used for username"
|
||||||
<%= ldap_form.text_field :uid_attribute, class: "input input-bordered", placeholder: "uid" %>
|
)) do %>
|
||||||
<label class="label">
|
<%= ldap_form.text_field :uid_attribute, class: "input input-bordered w-full", placeholder: "uid" %>
|
||||||
<span class="label-text-alt">Attribute used for username (default: uid)</span>
|
<label class="label">
|
||||||
</label>
|
<span class="label-text-alt">Default: uid</span>
|
||||||
</div>
|
</label>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<div class="form-control mt-1 w-full">
|
<%= render(FormFieldComponent.new(
|
||||||
<label class="label">
|
label: "Email Attribute",
|
||||||
<span class="label-text">Email Attribute</span>
|
description: "Attribute used for email address"
|
||||||
</label>
|
)) do %>
|
||||||
<%= ldap_form.text_field :email_attribute, class: "input input-bordered", placeholder: "mail" %>
|
<%= ldap_form.text_field :email_attribute, class: "input input-bordered w-full", placeholder: "mail" %>
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text-alt">Attribute used for email (default: mail)</span>
|
<span class="label-text-alt">Default: mail</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
<% end %>
|
||||||
|
|
||||||
<div class="form-control mt-1 w-full">
|
<%= render(FormFieldComponent.new(
|
||||||
<label class="label">
|
label: "Name Attribute",
|
||||||
<span class="label-text">Name Attribute</span>
|
description: "Attribute used for display name"
|
||||||
</label>
|
)) do %>
|
||||||
<%= ldap_form.text_field :name_attribute, class: "input input-bordered", placeholder: "cn" %>
|
<%= ldap_form.text_field :name_attribute, class: "input input-bordered w-full", placeholder: "cn" %>
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text-alt">Attribute used for display name (default: cn)</span>
|
<span class="label-text-alt">Default: cn</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
<% end %>
|
||||||
|
|
||||||
<div class="form-control mt-1 w-full">
|
<%= render(FormFieldComponent.new(
|
||||||
<label class="label">
|
label: "Filter",
|
||||||
<span class="label-text">Filter</span>
|
description: "Additional LDAP filter for user searches"
|
||||||
</label>
|
)) do %>
|
||||||
<%= ldap_form.text_field :filter, class: "input input-bordered", placeholder: "(objectClass=person)" %>
|
<%= ldap_form.text_field :filter, class: "input input-bordered w-full", placeholder: "(objectClass=person)" %>
|
||||||
<label class="label">
|
<% end %>
|
||||||
<span class="label-text-alt">Additional LDAP filter for user searches</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<div class="mt-6 mb-4" data-controller="ldap-test-connection">
|
<div class="mt-8" data-controller="ldap-test-connection">
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="btn btn-outline btn-sm"
|
class="btn btn-outline btn-sm"
|
||||||
data-action="click->ldap-test-connection#test"
|
data-action="click->ldap-test-connection#test"
|
||||||
|
|||||||
@@ -1,46 +1,58 @@
|
|||||||
<%= settings_layout do %>
|
<%= settings_layout do %>
|
||||||
<%= turbo_frame_tag "sso_provider" do %>
|
<%= turbo_frame_tag "sso_provider" do %>
|
||||||
<div class="font-lg font-bold mt-4">
|
<div class="font-lg font-bold mt-4">
|
||||||
Add LDAP Provider
|
Add <%= @provider_type == "oidc" ? "OIDC" : "LDAP" %> Provider
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-sm text-gray-500 mt-2 mb-4">
|
<div class="text-sm text-gray-500 mt-2 mb-4">
|
||||||
Configure an LDAP directory for SSO authentication. This will allow users to sign in using their LDAP credentials.
|
<% if @provider_type == "oidc" %>
|
||||||
|
Configure an OpenID Connect provider for SSO authentication. This will allow users to sign in using your identity provider.
|
||||||
|
<% else %>
|
||||||
|
Configure an LDAP directory for SSO authentication. This will allow users to sign in using their LDAP credentials.
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= form_with model: @sso_provider, url: sso_provider_path do |form| %>
|
<div class="tabs tabs-boxed mb-4 max-w-sm">
|
||||||
|
<%= link_to "LDAP", new_sso_provider_path(provider_type: "ldap"), class: "tab #{@provider_type == 'ldap' ? 'tab-active' : ''}" %>
|
||||||
|
<%= link_to "OIDC", new_sso_provider_path(provider_type: "oidc"), class: "tab #{@provider_type == 'oidc' ? 'tab-active' : ''}" %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= form_with model: @sso_provider, url: sso_provider_path(provider_type: @provider_type) do |form| %>
|
||||||
<%= render "shared/error_messages", resource: form.object %>
|
<%= render "shared/error_messages", resource: form.object %>
|
||||||
|
|
||||||
<div class="form-control mt-4 w-full max-w-sm">
|
<div class="space-y-8">
|
||||||
<label class="label">
|
<%= render(FormFieldComponent.new(
|
||||||
<span class="label-text">Provider Name</span>
|
label: "Provider Name",
|
||||||
</label>
|
description: "A friendly name for this SSO provider"
|
||||||
<%= form.text_field :name, class: "input input-bordered", required: true, placeholder: "e.g., Company SSO" %>
|
)) do %>
|
||||||
<label class="label">
|
<%= form.text_field :name, class: "input input-bordered w-full", required: true, placeholder: "e.g., Company SSO" %>
|
||||||
<span class="label-text-alt">A friendly name for this SSO provider</span>
|
<label class="label">
|
||||||
</label>
|
<span class="label-text-alt">* Required</span>
|
||||||
</div>
|
</label>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<div class="form-control mt-1 w-full max-w-sm">
|
<%= render(FormFieldComponent.new(
|
||||||
<label class="label cursor-pointer justify-start gap-2">
|
label: "Enabled",
|
||||||
|
description: "Enable or disable this SSO provider"
|
||||||
|
)) do %>
|
||||||
<%= form.check_box :enabled, class: "checkbox checkbox-primary" %>
|
<%= form.check_box :enabled, class: "checkbox checkbox-primary" %>
|
||||||
<span class="label-text">Enable this provider</span>
|
<% end %>
|
||||||
</label>
|
|
||||||
|
<%= render(FormFieldComponent.new(
|
||||||
|
label: "Team Provisioning Mode",
|
||||||
|
description: "How teams are provisioned for SSO users"
|
||||||
|
)) do %>
|
||||||
|
<%= form.select :team_provisioning_mode, SSOProvider.team_provisioning_modes.keys.map { |k| [k.titleize, k] }, {}, class: "select select-bordered w-full" %>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-control mt-4 w-full max-w-sm">
|
<% if @provider_type == "oidc" %>
|
||||||
<label class="label">
|
<div class="divider">OIDC Configuration</div>
|
||||||
<span class="label-text">Team Provisioning Mode</span>
|
<%= render "accounts/sso_providers/oidc/form_fields", oidc_configuration: @oidc_configuration %>
|
||||||
</label>
|
<% else %>
|
||||||
<%= form.select :team_provisioning_mode, SSOProvider.team_provisioning_modes.keys.map { |k| [k.titleize, k] }, {}, class: "select select-bordered" %>
|
<div class="divider">LDAP Configuration</div>
|
||||||
<label class="label">
|
<%= render "accounts/sso_providers/ldap/form_fields", ldap_configuration: @ldap_configuration %>
|
||||||
<span class="label-text-alt">How teams are provisioned for SSO users</span>
|
<% end %>
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="divider">LDAP Configuration</div>
|
|
||||||
|
|
||||||
<%= render "accounts/sso_providers/ldap/form_fields", ldap_configuration: @ldap_configuration %>
|
|
||||||
|
|
||||||
<div class="form-footer mt-6">
|
<div class="form-footer mt-6">
|
||||||
<%= form.submit "Create Provider", class: "btn btn-primary" %>
|
<%= form.submit "Create Provider", class: "btn btn-primary" %>
|
||||||
|
|||||||
121
app/views/accounts/sso_providers/oidc/_form_fields.html.erb
Normal file
121
app/views/accounts/sso_providers/oidc/_form_fields.html.erb
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
<%= render "accounts/sso_providers/oidc/redirect_uri_info" %>
|
||||||
|
|
||||||
|
<%= fields_for :oidc_configuration, oidc_configuration do |oidc_form| %>
|
||||||
|
<div class="space-y-8">
|
||||||
|
<%= render(FormFieldComponent.new(
|
||||||
|
label: "Issuer URL",
|
||||||
|
description: "The OIDC provider's issuer URL (used for discovery)"
|
||||||
|
)) do %>
|
||||||
|
<%= oidc_form.text_field :issuer, class: "input input-bordered w-full", required: true, placeholder: "https://auth.example.com" %>
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text-alt">* Required</span>
|
||||||
|
</label>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= render(FormFieldComponent.new(
|
||||||
|
label: "Client ID",
|
||||||
|
description: "OAuth 2.0 client identifier"
|
||||||
|
)) do %>
|
||||||
|
<%= oidc_form.text_field :client_id, class: "input input-bordered w-full", required: true, placeholder: "your-client-id" %>
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text-alt">* Required</span>
|
||||||
|
</label>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= render(FormFieldComponent.new(
|
||||||
|
label: "Client Secret",
|
||||||
|
description: "OAuth 2.0 client secret"
|
||||||
|
)) do %>
|
||||||
|
<%= oidc_form.text_field :client_secret, type: "password", class: "input input-bordered w-full", required: true %>
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text-alt">* Required</span>
|
||||||
|
</label>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= render(FormFieldComponent.new(
|
||||||
|
label: "Scopes",
|
||||||
|
description: "Space-separated list of scopes to request"
|
||||||
|
)) do %>
|
||||||
|
<%= oidc_form.text_field :scopes, class: "input input-bordered w-full", placeholder: "openid email profile" %>
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text-alt">Default: openid email profile</span>
|
||||||
|
</label>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details class="collapse collapse-arrow bg-base-200 mt-8">
|
||||||
|
<summary class="collapse-title font-medium">
|
||||||
|
Advanced Settings (Optional)
|
||||||
|
</summary>
|
||||||
|
<div class="collapse-content">
|
||||||
|
<div class="alert mt-4 mb-6">
|
||||||
|
<iconify-icon icon="lucide:info" class="mr-1"></iconify-icon>
|
||||||
|
<span class="text-sm">Leave these blank to use OIDC discovery. Only fill in if your provider doesn't support discovery.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-8">
|
||||||
|
<%= render(FormFieldComponent.new(
|
||||||
|
label: "Authorization Endpoint",
|
||||||
|
description: "URL for authorization requests"
|
||||||
|
)) do %>
|
||||||
|
<%= oidc_form.text_field :authorization_endpoint, class: "input input-bordered w-full", placeholder: "https://auth.example.com/authorize" %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= render(FormFieldComponent.new(
|
||||||
|
label: "Token Endpoint",
|
||||||
|
description: "URL for token requests"
|
||||||
|
)) do %>
|
||||||
|
<%= oidc_form.text_field :token_endpoint, class: "input input-bordered w-full", placeholder: "https://auth.example.com/oauth/token" %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= render(FormFieldComponent.new(
|
||||||
|
label: "UserInfo Endpoint",
|
||||||
|
description: "URL for fetching user information"
|
||||||
|
)) do %>
|
||||||
|
<%= oidc_form.text_field :userinfo_endpoint, class: "input input-bordered w-full", placeholder: "https://auth.example.com/userinfo" %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= render(FormFieldComponent.new(
|
||||||
|
label: "JWKS URI",
|
||||||
|
description: "URL for JSON Web Key Set"
|
||||||
|
)) do %>
|
||||||
|
<%= oidc_form.text_field :jwks_uri, class: "input input-bordered w-full", placeholder: "https://auth.example.com/.well-known/jwks.json" %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider">Claim Mappings</div>
|
||||||
|
|
||||||
|
<div class="space-y-8">
|
||||||
|
<%= render(FormFieldComponent.new(
|
||||||
|
label: "UID Claim",
|
||||||
|
description: "Claim used as user identifier"
|
||||||
|
)) do %>
|
||||||
|
<%= oidc_form.text_field :uid_claim, class: "input input-bordered w-full", placeholder: "sub" %>
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text-alt">Default: sub</span>
|
||||||
|
</label>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= render(FormFieldComponent.new(
|
||||||
|
label: "Email Claim",
|
||||||
|
description: "Claim used for email address"
|
||||||
|
)) do %>
|
||||||
|
<%= oidc_form.text_field :email_claim, class: "input input-bordered w-full", placeholder: "email" %>
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text-alt">Default: email</span>
|
||||||
|
</label>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= render(FormFieldComponent.new(
|
||||||
|
label: "Name Claim",
|
||||||
|
description: "Claim used for display name"
|
||||||
|
)) do %>
|
||||||
|
<%= oidc_form.text_field :name_claim, class: "input input-bordered w-full", placeholder: "name" %>
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text-alt">Default: name</span>
|
||||||
|
</label>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<% end %>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<div class="alert alert-warning mb-4">
|
||||||
|
<iconify-icon icon="lucide:info" class="mr-2"></iconify-icon>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium">Redirect URI</div>
|
||||||
|
<div class="text-sm mt-1">Configure this redirect URI in your OIDC provider:</div>
|
||||||
|
<pre
|
||||||
|
class="inline-block cursor-pointer mt-2"
|
||||||
|
data-controller="clipboard"
|
||||||
|
data-clipboard-text="<%= oidc_callback_url(slug: current_account.slug) %>"><%= oidc_callback_url(slug: current_account.slug) %></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -10,12 +10,17 @@
|
|||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-semibold"><%= @sso_provider.name %></h3>
|
<h3 class="text-lg font-semibold"><%= @sso_provider.name %></h3>
|
||||||
<div class="flex gap-2 mt-2">
|
<div class="flex gap-2 mt-2">
|
||||||
<span class="badge badge-outline">LDAP</span>
|
<% if @sso_provider.ldap? %>
|
||||||
|
<span class="badge badge-outline">LDAP</span>
|
||||||
|
<% elsif @sso_provider.oidc? %>
|
||||||
|
<span class="badge badge-outline">OIDC</span>
|
||||||
|
<% end %>
|
||||||
<% if @sso_provider.enabled %>
|
<% if @sso_provider.enabled %>
|
||||||
<span class="badge badge-success">Enabled</span>
|
<span class="badge badge-success">Enabled</span>
|
||||||
<% else %>
|
<% else %>
|
||||||
<span class="badge badge-ghost">Disabled</span>
|
<span class="badge badge-ghost">Disabled</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
<span class="badge badge-info"><%= @sso_provider.sso_users_count %> <%= "user".pluralize(@sso_provider.sso_users_count) %></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
@@ -26,24 +31,46 @@
|
|||||||
|
|
||||||
<% if @configuration %>
|
<% if @configuration %>
|
||||||
<div class="divider"></div>
|
<div class="divider"></div>
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<% if @sso_provider.ldap? %>
|
||||||
<div>
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div class="text-sm font-medium text-base-content/70 mb-1">Host</div>
|
<div>
|
||||||
<div class="font-mono"><%= @configuration.host %>:<%= @configuration.port %></div>
|
<div class="text-sm font-medium text-base-content/70 mb-1">Host</div>
|
||||||
|
<div class="font-mono"><%= @configuration.host %>:<%= @configuration.port %></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-medium text-base-content/70 mb-1">Base DN</div>
|
||||||
|
<div class="font-mono"><%= @configuration.base_dn %></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-medium text-base-content/70 mb-1">UID Attribute</div>
|
||||||
|
<div class="font-mono"><%= @configuration.uid_attribute %></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-medium text-base-content/70 mb-1">Encryption</div>
|
||||||
|
<div class="font-mono"><%= @configuration.encryption.titleize %></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<% elsif @sso_provider.oidc? %>
|
||||||
<div class="text-sm font-medium text-base-content/70 mb-1">Base DN</div>
|
<%= render "accounts/sso_providers/oidc/redirect_uri_info" %>
|
||||||
<div class="font-mono"><%= @configuration.base_dn %></div>
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-medium text-base-content/70 mb-1">Issuer</div>
|
||||||
|
<div class="font-mono text-sm break-all"><%= @configuration.issuer %></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-medium text-base-content/70 mb-1">Client ID</div>
|
||||||
|
<div class="font-mono text-sm"><%= @configuration.client_id %></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-medium text-base-content/70 mb-1">Scopes</div>
|
||||||
|
<div class="font-mono text-sm"><%= @configuration.scopes %></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-medium text-base-content/70 mb-1">UID Claim</div>
|
||||||
|
<div class="font-mono text-sm"><%= @configuration.uid_claim %></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<% end %>
|
||||||
<div class="text-sm font-medium text-base-content/70 mb-1">UID Attribute</div>
|
|
||||||
<div class="font-mono"><%= @configuration.uid_attribute %></div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="text-sm font-medium text-base-content/70 mb-1">Encryption</div>
|
|
||||||
<div class="font-mono"><%= @configuration.encryption.titleize %></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -54,7 +81,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<%= link_to "+ Add LDAP Provider", new_sso_provider_path, class: "btn btn-primary btn-sm" %>
|
<%= link_to "+ Add LDAP Provider", new_sso_provider_path(provider_type: "ldap"), class: "btn btn-primary btn-sm" %>
|
||||||
|
<%= link_to "+ Add OIDC Provider", new_sso_provider_path(provider_type: "oidc"), class: "btn btn-primary btn-sm" %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
40
app/views/devise/sessions/oidc.html.erb
Normal file
40
app/views/devise/sessions/oidc.html.erb
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<div class="flex justify-center items-center min-h-screen bg-base-200">
|
||||||
|
<div class="card w-96 bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h1 class="text-2xl font-bold text-center">Sign in</h1>
|
||||||
|
<div class="text-center text-sm">
|
||||||
|
Sign in to access your account
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if @account.stack_manager&.portainer? %>
|
||||||
|
<div class="flex justify-center mt-2">
|
||||||
|
<%= render "devise/sessions/portainer_badge", stack_manager: @account.stack_manager, logged_in: false %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<% if flash[:alert] %>
|
||||||
|
<div class="alert alert-error mb-4 mt-4">
|
||||||
|
<iconify-icon icon="lucide:alert-triangle" class="mr-2 text-white"></iconify-icon>
|
||||||
|
<span><%= flash[:alert] %></span>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<%= link_to oidc_auth_path(@account.slug), class: "btn btn-primary w-full", data: { turbo: false } do %>
|
||||||
|
<iconify-icon icon="lucide:log-in" height="18"></iconify-icon>
|
||||||
|
Sign in with <%= @sso_provider.name %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider">New to <%= @account.name %>?</div>
|
||||||
|
|
||||||
|
<p class="text-center text-sm">
|
||||||
|
Contact your account administrator to get access
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-4 text-center">
|
||||||
|
<%= link_to "Back to main login", new_user_session_path, class: "link link-primary text-sm" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<% current_user.providers.each do |provider| %>
|
<% current_user.providers.non_sso.each do |provider| %>
|
||||||
<% project_credential_providers = ProjectCredentialProvider.where(provider:).all %>
|
<% project_credential_providers = ProjectCredentialProvider.where(provider:).all %>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
|
|||||||
@@ -14,4 +14,5 @@
|
|||||||
ActiveSupport::Inflector.inflections(:en) do |inflect|
|
ActiveSupport::Inflector.inflections(:en) do |inflect|
|
||||||
inflect.acronym "SSO"
|
inflect.acronym "SSO"
|
||||||
inflect.acronym "LDAP"
|
inflect.acronym "LDAP"
|
||||||
|
inflect.acronym "OIDC"
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ Rails.application.routes.draw do
|
|||||||
post '/accounts/:slug/sign_in', to: 'users/sessions#account_create'
|
post '/accounts/:slug/sign_in', to: 'users/sessions#account_create'
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# OIDC authentication routes
|
||||||
|
get '/accounts/:slug/auth/oidc', to: 'accounts/oidc#authorize', as: :oidc_auth
|
||||||
|
get '/accounts/:slug/auth/oidc/callback', to: 'accounts/oidc#callback', as: :oidc_callback
|
||||||
|
|
||||||
authenticate :user, ->(user) { user.admin? } do
|
authenticate :user, ->(user) { user.admin? } do
|
||||||
mount Avo::Engine, at: Avo.configuration.root_path
|
mount Avo::Engine, at: Avo.configuration.root_path
|
||||||
Avo::Engine.routes.draw do
|
Avo::Engine.routes.draw do
|
||||||
|
|||||||
19
db/migrate/20251211151950_create_oidc_configurations.rb
Normal file
19
db/migrate/20251211151950_create_oidc_configurations.rb
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
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.string :uid_claim, default: "sub", null: false
|
||||||
|
t.string :email_claim, default: "email"
|
||||||
|
t.string :name_claim, default: "name"
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
class AddSSOProviderToProviders < ActiveRecord::Migration[7.2]
|
||||||
|
def change
|
||||||
|
add_reference :providers, :sso_provider, null: true, foreign_key: true
|
||||||
|
add_index :providers, [ :sso_provider_id, :uid ], unique: true, where: "sso_provider_id IS NOT NULL"
|
||||||
|
end
|
||||||
|
end
|
||||||
22
db/schema.rb
generated
22
db/schema.rb
generated
@@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[7.2].define(version: 2025_11_26_014509) do
|
ActiveRecord::Schema[7.2].define(version: 2025_12_12_215414) do
|
||||||
# These are extensions that must be enabled in order to support this database
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "plpgsql"
|
enable_extension "plpgsql"
|
||||||
|
|
||||||
@@ -393,6 +393,22 @@ ActiveRecord::Schema[7.2].define(version: 2025_11_26_014509) do
|
|||||||
t.index ["recipient_type", "recipient_id"], name: "index_noticed_notifications_on_recipient"
|
t.index ["recipient_type", "recipient_id"], name: "index_noticed_notifications_on_recipient"
|
||||||
end
|
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.string "uid_claim", default: "sub", null: false
|
||||||
|
t.string "email_claim", default: "email"
|
||||||
|
t.string "name_claim", default: "name"
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
end
|
||||||
|
|
||||||
create_table "project_add_ons", force: :cascade do |t|
|
create_table "project_add_ons", force: :cascade do |t|
|
||||||
t.bigint "project_id", null: false
|
t.bigint "project_id", null: false
|
||||||
t.bigint "add_on_id", null: false
|
t.bigint "add_on_id", null: false
|
||||||
@@ -464,6 +480,9 @@ ActiveRecord::Schema[7.2].define(version: 2025_11_26_014509) do
|
|||||||
t.datetime "last_used_at"
|
t.datetime "last_used_at"
|
||||||
t.string "registry_url"
|
t.string "registry_url"
|
||||||
t.string "external_id"
|
t.string "external_id"
|
||||||
|
t.bigint "sso_provider_id"
|
||||||
|
t.index ["sso_provider_id", "uid"], name: "index_providers_on_sso_provider_id_and_uid", unique: true, where: "(sso_provider_id IS NOT NULL)"
|
||||||
|
t.index ["sso_provider_id"], name: "index_providers_on_sso_provider_id"
|
||||||
t.index ["user_id"], name: "index_providers_on_user_id"
|
t.index ["user_id"], name: "index_providers_on_user_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -607,6 +626,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_11_26_014509) do
|
|||||||
add_foreign_key "project_forks", "projects", column: "parent_project_id"
|
add_foreign_key "project_forks", "projects", column: "parent_project_id"
|
||||||
add_foreign_key "projects", "clusters"
|
add_foreign_key "projects", "clusters"
|
||||||
add_foreign_key "projects", "clusters", column: "project_fork_cluster_id"
|
add_foreign_key "projects", "clusters", column: "project_fork_cluster_id"
|
||||||
|
add_foreign_key "providers", "sso_providers"
|
||||||
add_foreign_key "providers", "users"
|
add_foreign_key "providers", "users"
|
||||||
add_foreign_key "services", "projects"
|
add_foreign_key "services", "projects"
|
||||||
add_foreign_key "sso_providers", "accounts"
|
add_foreign_key "sso_providers", "accounts"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ x-shared-env: &shared-env
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
|
restart: unless-stopped
|
||||||
image: postgres:16-bookworm@sha256:878977a5fe8d75ba7eab7610e4cf7e0c8626a683d89b3f9da965b8ceba952a09
|
image: postgres:16-bookworm@sha256:878977a5fe8d75ba7eab7610e4cf7e0c8626a683d89b3f9da965b8ceba952a09
|
||||||
environment:
|
environment:
|
||||||
- POSTGRES_USER=postgres
|
- POSTGRES_USER=postgres
|
||||||
@@ -17,13 +18,20 @@ services:
|
|||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- "postgres:/var/lib/postgresql/data"
|
- "postgres:/var/lib/postgresql/data"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||||
|
interval: 30s
|
||||||
|
retries: 5
|
||||||
|
start_period: 20s
|
||||||
|
|
||||||
web:
|
web:
|
||||||
|
restart: unless-stopped
|
||||||
image: ghcr.io/caninehq/canine:latest
|
image: ghcr.io/caninehq/canine:latest
|
||||||
# Overrides default command so things don't shut down after the process ends.
|
# Overrides default command so things don't shut down after the process ends.
|
||||||
# command: sleep infinity
|
# command: sleep infinity
|
||||||
depends_on:
|
depends_on:
|
||||||
- postgres
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
stdin_open: true
|
stdin_open: true
|
||||||
tty: true
|
tty: true
|
||||||
ports:
|
ports:
|
||||||
@@ -37,10 +45,12 @@ services:
|
|||||||
- ${DOCKER_SOCKET:-/var/run/docker.sock}:/var/run/docker.sock
|
- ${DOCKER_SOCKET:-/var/run/docker.sock}:/var/run/docker.sock
|
||||||
|
|
||||||
worker:
|
worker:
|
||||||
|
restart: unless-stopped
|
||||||
image: ghcr.io/caninehq/canine:latest
|
image: ghcr.io/caninehq/canine:latest
|
||||||
command: bundle exec good_job start
|
command: bundle exec good_job start
|
||||||
depends_on:
|
depends_on:
|
||||||
- postgres
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
<<: *shared-env
|
<<: *shared-env
|
||||||
LOCAL_MODE: "true"
|
LOCAL_MODE: "true"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ x-shared-env: &shared-env
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
|
restart: unless-stopped
|
||||||
image: postgres:16-bookworm@sha256:878977a5fe8d75ba7eab7610e4cf7e0c8626a683d89b3f9da965b8ceba952a09
|
image: postgres:16-bookworm@sha256:878977a5fe8d75ba7eab7610e4cf7e0c8626a683d89b3f9da965b8ceba952a09
|
||||||
environment:
|
environment:
|
||||||
- POSTGRES_USER=postgres
|
- POSTGRES_USER=postgres
|
||||||
@@ -17,13 +18,20 @@ services:
|
|||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- "postgres:/var/lib/postgresql/data"
|
- "postgres:/var/lib/postgresql/data"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||||
|
interval: 30s
|
||||||
|
retries: 5
|
||||||
|
start_period: 20s
|
||||||
|
|
||||||
web:
|
web:
|
||||||
|
restart: unless-stopped
|
||||||
image: ghcr.io/caninehq/canine:latest
|
image: ghcr.io/caninehq/canine:latest
|
||||||
# Overrides default command so things don't shut down after the process ends.
|
# Overrides default command so things don't shut down after the process ends.
|
||||||
# command: sleep infinity
|
# command: sleep infinity
|
||||||
depends_on:
|
depends_on:
|
||||||
- postgres
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
stdin_open: true
|
stdin_open: true
|
||||||
tty: true
|
tty: true
|
||||||
ports:
|
ports:
|
||||||
@@ -37,10 +45,12 @@ services:
|
|||||||
- ${DOCKER_SOCKET:-/var/run/docker.sock}:/var/run/docker.sock
|
- ${DOCKER_SOCKET:-/var/run/docker.sock}:/var/run/docker.sock
|
||||||
|
|
||||||
worker:
|
worker:
|
||||||
|
restart: unless-stopped
|
||||||
image: ghcr.io/caninehq/canine:latest
|
image: ghcr.io/caninehq/canine:latest
|
||||||
command: bundle exec good_job start
|
command: bundle exec good_job start
|
||||||
depends_on:
|
depends_on:
|
||||||
- postgres
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
<<: *shared-env
|
<<: *shared-env
|
||||||
LOCAL_MODE: "true"
|
LOCAL_MODE: "true"
|
||||||
|
|||||||
@@ -51,17 +51,15 @@ module Devise
|
|||||||
|
|
||||||
email = result.email
|
email = result.email
|
||||||
|
|
||||||
if sso_provider.just_in_time_team_provisioning_mode?
|
ar_result = SSO::SyncUserTeams.call(
|
||||||
groups = result.groups
|
email: email,
|
||||||
ar_result = ActiveRecord::Base.transaction do
|
team_names: result.groups || [],
|
||||||
SSO::SyncUserTeams.call(email, groups, ldap_configuration.account)
|
account: ldap_configuration.account,
|
||||||
end
|
sso_provider: sso_provider,
|
||||||
else
|
uid: result.user_dn,
|
||||||
ar_result = SSO::CreateUserInAccount.execute(
|
name: result.name,
|
||||||
email: email,
|
create_teams: sso_provider.just_in_time_team_provisioning_mode?
|
||||||
account: ldap_configuration.account,
|
)
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
if ar_result.failure?
|
if ar_result.failure?
|
||||||
return fail(:invalid_login)
|
return fail(:invalid_login)
|
||||||
|
|||||||
@@ -2,26 +2,101 @@ require 'rails_helper'
|
|||||||
|
|
||||||
RSpec.describe SSO::CreateUserInAccount do
|
RSpec.describe SSO::CreateUserInAccount do
|
||||||
let(:account) { create(:account) }
|
let(:account) { create(:account) }
|
||||||
|
let(:sso_provider) { create(:sso_provider, account: account) }
|
||||||
|
|
||||||
describe '.execute' do
|
describe '.execute' do
|
||||||
it 'creates a new user and associates them with the account when user does not exist' do
|
it 'creates a new user, provider record, and associates them with the account' do
|
||||||
result = described_class.execute(email: 'new@example.com', account: account)
|
result = described_class.execute(
|
||||||
|
email: 'new@example.com',
|
||||||
|
account: account,
|
||||||
|
sso_provider: sso_provider,
|
||||||
|
uid: 'external-uid-123'
|
||||||
|
)
|
||||||
|
|
||||||
expect(result).to be_success
|
expect(result).to be_success
|
||||||
expect(result.user).to be_persisted
|
expect(result.user).to be_persisted
|
||||||
expect(result.user.email).to eq('new@example.com')
|
expect(result.user.email).to eq('new@example.com')
|
||||||
expect(account.users).to include(result.user)
|
expect(account.users).to include(result.user)
|
||||||
|
|
||||||
|
provider = Provider.find_by(sso_provider: sso_provider, uid: 'external-uid-123')
|
||||||
|
expect(provider).to be_present
|
||||||
|
expect(provider.user).to eq(result.user)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'associates an existing user with the account without creating a duplicate' do
|
it 'sets first and last name when name is provided' do
|
||||||
|
result = described_class.execute(
|
||||||
|
email: 'new@example.com',
|
||||||
|
account: account,
|
||||||
|
sso_provider: sso_provider,
|
||||||
|
uid: 'external-uid-123',
|
||||||
|
name: 'John Doe'
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result).to be_success
|
||||||
|
expect(result.user.first_name).to eq('John')
|
||||||
|
expect(result.user.last_name).to eq('Doe')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'finds existing user by SSO provider uid on subsequent logins' do
|
||||||
|
first_result = described_class.execute(
|
||||||
|
email: 'user@example.com',
|
||||||
|
account: account,
|
||||||
|
sso_provider: sso_provider,
|
||||||
|
uid: 'same-uid'
|
||||||
|
)
|
||||||
|
|
||||||
|
second_result = described_class.execute(
|
||||||
|
email: 'different@example.com',
|
||||||
|
account: account,
|
||||||
|
sso_provider: sso_provider,
|
||||||
|
uid: 'same-uid'
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(second_result).to be_success
|
||||||
|
expect(second_result.user).to eq(first_result.user)
|
||||||
|
expect(Provider.where(sso_provider: sso_provider, uid: 'same-uid').count).to eq(1)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'updates name on subsequent logins' do
|
||||||
|
first_result = described_class.execute(
|
||||||
|
email: 'user@example.com',
|
||||||
|
account: account,
|
||||||
|
sso_provider: sso_provider,
|
||||||
|
uid: 'same-uid',
|
||||||
|
name: 'Old Name'
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(first_result.user.first_name).to eq('Old')
|
||||||
|
expect(first_result.user.last_name).to eq('Name')
|
||||||
|
|
||||||
|
second_result = described_class.execute(
|
||||||
|
email: 'user@example.com',
|
||||||
|
account: account,
|
||||||
|
sso_provider: sso_provider,
|
||||||
|
uid: 'same-uid',
|
||||||
|
name: 'New Name'
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(second_result.user.reload.first_name).to eq('New')
|
||||||
|
expect(second_result.user.last_name).to eq('Name')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'links existing user by email if no provider record exists' do
|
||||||
existing_user = create(:user, email: 'existing@example.com')
|
existing_user = create(:user, email: 'existing@example.com')
|
||||||
|
|
||||||
result = described_class.execute(email: 'EXISTING@example.com', account: account)
|
result = described_class.execute(
|
||||||
|
email: 'EXISTING@example.com',
|
||||||
|
account: account,
|
||||||
|
sso_provider: sso_provider,
|
||||||
|
uid: 'new-uid'
|
||||||
|
)
|
||||||
|
|
||||||
expect(result).to be_success
|
expect(result).to be_success
|
||||||
expect(result.user).to eq(existing_user)
|
expect(result.user).to eq(existing_user)
|
||||||
expect(account.users).to include(existing_user)
|
expect(account.users).to include(existing_user)
|
||||||
expect(User.where(email: 'existing@example.com').count).to eq(1)
|
|
||||||
|
provider = Provider.find_by(sso_provider: sso_provider, uid: 'new-uid')
|
||||||
|
expect(provider.user).to eq(existing_user)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -2,12 +2,20 @@ require 'rails_helper'
|
|||||||
|
|
||||||
RSpec.describe SSO::SyncUserTeams do
|
RSpec.describe SSO::SyncUserTeams do
|
||||||
let(:account) { create(:account) }
|
let(:account) { create(:account) }
|
||||||
|
let(:sso_provider) { create(:sso_provider, account: account) }
|
||||||
|
|
||||||
describe '.call' do
|
describe '.call' do
|
||||||
it 'creates user, teams, and team memberships' do
|
it 'creates user, teams, and team memberships' do
|
||||||
create(:team, account: account, name: 'Existing')
|
create(:team, account: account, name: 'Existing')
|
||||||
|
|
||||||
result = described_class.call('new@example.com', [ { name: 'Existing' }, { name: 'NewTeam' } ], account)
|
result = described_class.call(
|
||||||
|
email: 'new@example.com',
|
||||||
|
team_names: [ { name: 'Existing' }, { name: 'NewTeam' } ],
|
||||||
|
account: account,
|
||||||
|
sso_provider: sso_provider,
|
||||||
|
uid: 'user-uid-123',
|
||||||
|
create_teams: true
|
||||||
|
)
|
||||||
|
|
||||||
expect(result).to be_success
|
expect(result).to be_success
|
||||||
expect(result.user.email).to eq('new@example.com')
|
expect(result.user.email).to eq('new@example.com')
|
||||||
@@ -22,8 +30,16 @@ RSpec.describe SSO::SyncUserTeams do
|
|||||||
create(:team_membership, user: user, team: team_to_keep)
|
create(:team_membership, user: user, team: team_to_keep)
|
||||||
create(:team_membership, user: user, team: team_to_remove)
|
create(:team_membership, user: user, team: team_to_remove)
|
||||||
create(:account_user, account: account, user: user)
|
create(:account_user, account: account, user: user)
|
||||||
|
create(:provider, user: user, sso_provider: sso_provider, uid: 'existing-uid', provider: sso_provider.name)
|
||||||
|
|
||||||
result = described_class.call('existing@example.com', [ { name: 'KeepTeam' } ], account)
|
result = described_class.call(
|
||||||
|
email: 'existing@example.com',
|
||||||
|
team_names: [ { name: 'KeepTeam' } ],
|
||||||
|
account: account,
|
||||||
|
sso_provider: sso_provider,
|
||||||
|
uid: 'existing-uid',
|
||||||
|
create_teams: true
|
||||||
|
)
|
||||||
|
|
||||||
expect(result).to be_success
|
expect(result).to be_success
|
||||||
expect(result.user.teams.reload).to contain_exactly(team_to_keep)
|
expect(result.user.teams.reload).to contain_exactly(team_to_keep)
|
||||||
@@ -37,8 +53,16 @@ RSpec.describe SSO::SyncUserTeams do
|
|||||||
create(:team_membership, user: user, team: team_in_account)
|
create(:team_membership, user: user, team: team_in_account)
|
||||||
create(:team_membership, user: user, team: team_in_other)
|
create(:team_membership, user: user, team: team_in_other)
|
||||||
create(:account_user, account: account, user: user)
|
create(:account_user, account: account, user: user)
|
||||||
|
create(:provider, user: user, sso_provider: sso_provider, uid: 'existing-uid', provider: sso_provider.name)
|
||||||
|
|
||||||
result = described_class.call('existing@example.com', [], account)
|
result = described_class.call(
|
||||||
|
email: 'existing@example.com',
|
||||||
|
team_names: [],
|
||||||
|
account: account,
|
||||||
|
sso_provider: sso_provider,
|
||||||
|
uid: 'existing-uid',
|
||||||
|
create_teams: true
|
||||||
|
)
|
||||||
|
|
||||||
expect(result).to be_success
|
expect(result).to be_success
|
||||||
expect(user.teams.reload).to contain_exactly(team_in_other)
|
expect(user.teams.reload).to contain_exactly(team_in_other)
|
||||||
|
|||||||
34
spec/factories/oidc_configurations.rb
Normal file
34
spec/factories/oidc_configurations.rb
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# == Schema Information
|
||||||
|
#
|
||||||
|
# Table name: oidc_configurations
|
||||||
|
#
|
||||||
|
# id :bigint not null, primary key
|
||||||
|
# issuer :string not null
|
||||||
|
# client_id :string not null
|
||||||
|
# client_secret :string not null
|
||||||
|
# authorization_endpoint :string
|
||||||
|
# token_endpoint :string
|
||||||
|
# userinfo_endpoint :string
|
||||||
|
# jwks_uri :string
|
||||||
|
# scopes :string default("openid email profile")
|
||||||
|
# uid_claim :string default("sub"), not null
|
||||||
|
# email_claim :string default("email")
|
||||||
|
# name_claim :string default("name")
|
||||||
|
# created_at :datetime not null
|
||||||
|
# updated_at :datetime not null
|
||||||
|
#
|
||||||
|
FactoryBot.define do
|
||||||
|
factory :oidc_configuration do
|
||||||
|
issuer { "https://auth.example.com" }
|
||||||
|
client_id { "canine-client" }
|
||||||
|
client_secret { "super-secret-key" }
|
||||||
|
authorization_endpoint { nil }
|
||||||
|
token_endpoint { nil }
|
||||||
|
userinfo_endpoint { nil }
|
||||||
|
jwks_uri { nil }
|
||||||
|
scopes { "openid email profile" }
|
||||||
|
uid_claim { "sub" }
|
||||||
|
email_claim { "email" }
|
||||||
|
name_claim { "name" }
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -15,14 +15,18 @@
|
|||||||
# created_at :datetime not null
|
# created_at :datetime not null
|
||||||
# updated_at :datetime not null
|
# updated_at :datetime not null
|
||||||
# external_id :string
|
# external_id :string
|
||||||
|
# sso_provider_id :bigint
|
||||||
# user_id :bigint not null
|
# user_id :bigint not null
|
||||||
#
|
#
|
||||||
# Indexes
|
# Indexes
|
||||||
#
|
#
|
||||||
# index_providers_on_user_id (user_id)
|
# index_providers_on_sso_provider_id (sso_provider_id)
|
||||||
|
# index_providers_on_sso_provider_id_and_uid (sso_provider_id,uid) UNIQUE WHERE (sso_provider_id IS NOT NULL)
|
||||||
|
# index_providers_on_user_id (user_id)
|
||||||
#
|
#
|
||||||
# Foreign Keys
|
# Foreign Keys
|
||||||
#
|
#
|
||||||
|
# fk_rails_... (sso_provider_id => sso_providers.id)
|
||||||
# fk_rails_... (user_id => users.id)
|
# fk_rails_... (user_id => users.id)
|
||||||
#
|
#
|
||||||
FactoryBot.define do
|
FactoryBot.define do
|
||||||
|
|||||||
@@ -23,9 +23,10 @@
|
|||||||
#
|
#
|
||||||
FactoryBot.define do
|
FactoryBot.define do
|
||||||
factory :sso_provider do
|
factory :sso_provider do
|
||||||
account { nil }
|
account
|
||||||
configuration { nil }
|
configuration { association :oidc_configuration }
|
||||||
name { "MyString" }
|
name { "Test SSO Provider" }
|
||||||
enabled { false }
|
enabled { true }
|
||||||
|
team_provisioning_mode { :disabled }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
64
spec/models/oidc_configuration_spec.rb
Normal file
64
spec/models/oidc_configuration_spec.rb
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
# == Schema Information
|
||||||
|
#
|
||||||
|
# Table name: oidc_configurations
|
||||||
|
#
|
||||||
|
# id :bigint not null, primary key
|
||||||
|
# issuer :string not null
|
||||||
|
# client_id :string not null
|
||||||
|
# client_secret :string not null
|
||||||
|
# authorization_endpoint :string
|
||||||
|
# token_endpoint :string
|
||||||
|
# userinfo_endpoint :string
|
||||||
|
# jwks_uri :string
|
||||||
|
# scopes :string default("openid email profile")
|
||||||
|
# uid_claim :string default("sub"), not null
|
||||||
|
# email_claim :string default("email")
|
||||||
|
# name_claim :string default("name")
|
||||||
|
# created_at :datetime not null
|
||||||
|
# updated_at :datetime not null
|
||||||
|
#
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe OIDCConfiguration, type: :model do
|
||||||
|
describe "validations" do
|
||||||
|
it "is valid with valid attributes" do
|
||||||
|
config = build(:oidc_configuration)
|
||||||
|
expect(config).to be_valid
|
||||||
|
end
|
||||||
|
|
||||||
|
it "requires issuer, client_id, and client_secret" do
|
||||||
|
config = build(:oidc_configuration, issuer: nil, client_id: nil, client_secret: nil)
|
||||||
|
expect(config).not_to be_valid
|
||||||
|
expect(config.errors[:issuer]).to be_present
|
||||||
|
expect(config.errors[:client_id]).to be_present
|
||||||
|
expect(config.errors[:client_secret]).to be_present
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "#discovery_url" do
|
||||||
|
it "returns the well-known configuration URL" do
|
||||||
|
config = build(:oidc_configuration, issuer: "https://auth.example.com")
|
||||||
|
expect(config.discovery_url).to eq("https://auth.example.com/.well-known/openid-configuration")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "strips trailing slash from issuer" do
|
||||||
|
config = build(:oidc_configuration, issuer: "https://auth.example.com/")
|
||||||
|
expect(config.discovery_url).to eq("https://auth.example.com/.well-known/openid-configuration")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "#uses_discovery?" do
|
||||||
|
it "returns true when endpoints are blank" do
|
||||||
|
config = build(:oidc_configuration, authorization_endpoint: nil, token_endpoint: nil)
|
||||||
|
expect(config.uses_discovery?).to be true
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns false when endpoints are configured" do
|
||||||
|
config = build(:oidc_configuration,
|
||||||
|
authorization_endpoint: "https://auth.example.com/authorize",
|
||||||
|
token_endpoint: "https://auth.example.com/token"
|
||||||
|
)
|
||||||
|
expect(config.uses_discovery?).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,3 +1,34 @@
|
|||||||
|
# == Schema Information
|
||||||
|
#
|
||||||
|
# Table name: providers
|
||||||
|
#
|
||||||
|
# id :bigint not null, primary key
|
||||||
|
# access_token :string
|
||||||
|
# access_token_secret :string
|
||||||
|
# auth :text
|
||||||
|
# expires_at :datetime
|
||||||
|
# last_used_at :datetime
|
||||||
|
# provider :string
|
||||||
|
# refresh_token :string
|
||||||
|
# registry_url :string
|
||||||
|
# uid :string
|
||||||
|
# created_at :datetime not null
|
||||||
|
# updated_at :datetime not null
|
||||||
|
# external_id :string
|
||||||
|
# sso_provider_id :bigint
|
||||||
|
# user_id :bigint not null
|
||||||
|
#
|
||||||
|
# Indexes
|
||||||
|
#
|
||||||
|
# index_providers_on_sso_provider_id (sso_provider_id)
|
||||||
|
# index_providers_on_sso_provider_id_and_uid (sso_provider_id,uid) UNIQUE WHERE (sso_provider_id IS NOT NULL)
|
||||||
|
# index_providers_on_user_id (user_id)
|
||||||
|
#
|
||||||
|
# Foreign Keys
|
||||||
|
#
|
||||||
|
# fk_rails_... (sso_provider_id => sso_providers.id)
|
||||||
|
# fk_rails_... (user_id => users.id)
|
||||||
|
#
|
||||||
require 'rails_helper'
|
require 'rails_helper'
|
||||||
|
|
||||||
RSpec.describe Provider, type: :model do
|
RSpec.describe Provider, type: :model do
|
||||||
|
|||||||
@@ -24,5 +24,28 @@
|
|||||||
require 'rails_helper'
|
require 'rails_helper'
|
||||||
|
|
||||||
RSpec.describe SSOProvider, type: :model do
|
RSpec.describe SSOProvider, type: :model do
|
||||||
pending "add some examples to (or delete) #{__FILE__}"
|
describe '#sso_users_count' do
|
||||||
|
let(:sso_provider) { create(:sso_provider) }
|
||||||
|
|
||||||
|
it 'returns 0 when no users have signed in' do
|
||||||
|
expect(sso_provider.sso_users_count).to eq(0)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'counts users who have signed in via SSO' do
|
||||||
|
user1 = create(:user)
|
||||||
|
user2 = create(:user)
|
||||||
|
create(:provider, user: user1, sso_provider: sso_provider, uid: 'uid-1', provider: sso_provider.name)
|
||||||
|
create(:provider, user: user2, sso_provider: sso_provider, uid: 'uid-2', provider: sso_provider.name)
|
||||||
|
|
||||||
|
expect(sso_provider.sso_users_count).to eq(2)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not count users from other SSO providers' do
|
||||||
|
other_sso_provider = create(:sso_provider, account: create(:account))
|
||||||
|
user = create(:user)
|
||||||
|
create(:provider, user: user, sso_provider: other_sso_provider, uid: 'uid-1', provider: other_sso_provider.name)
|
||||||
|
|
||||||
|
expect(sso_provider.sso_users_count).to eq(0)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
Reference in New Issue
Block a user