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 "net-ldap", "~> 0.20.0"
|
||||
|
||||
gem "jwt", "~> 2.9"
|
||||
|
||||
@@ -723,6 +723,7 @@ DEPENDENCIES
|
||||
importmap-rails
|
||||
jbuilder
|
||||
jsbundling-rails
|
||||
jwt (~> 2.9)
|
||||
k8s-ruby (~> 0.17.2)
|
||||
kubeclient (~> 4.12)
|
||||
light-service (~> 0.20.0)
|
||||
|
||||
@@ -3,20 +3,43 @@
|
||||
module SSO
|
||||
class CreateUserInAccount
|
||||
extend LightService::Action
|
||||
expects :email, :account
|
||||
expects :email, :account, :sso_provider, :uid
|
||||
expects :name, default: nil
|
||||
promises :user
|
||||
|
||||
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?
|
||||
password = SecureRandom.hex(32)
|
||||
user.password = password
|
||||
user.password_confirmation = password
|
||||
if provider
|
||||
user = provider.user
|
||||
else
|
||||
# Fall back to finding by email, or create new user
|
||||
user = User.find_or_initialize_by(email: context.email.downcase)
|
||||
|
||||
unless user.save
|
||||
context.fail_and_return!("Failed to create user", errors: user.errors)
|
||||
if user.new_record?
|
||||
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
|
||||
|
||||
# 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
|
||||
|
||||
AccountUser.find_or_create_by!(account: context.account, user: user)
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
class SSO::SyncUserTeams
|
||||
extend LightService::Organizer
|
||||
def self.call(email, team_names, account)
|
||||
with(email:, team_names:, account:).reduce(
|
||||
SSO::CreateTeamsInAccount,
|
||||
SSO::CreateUserInAccount,
|
||||
SSO::SyncTeams,
|
||||
)
|
||||
def self.call(email:, team_names:, account:, sso_provider:, uid:, name: nil, create_teams: false)
|
||||
actions = []
|
||||
actions << SSO::CreateTeamsInAccount if create_teams
|
||||
actions << SSO::CreateUserInAccount
|
||||
actions << SSO::SyncTeams if create_teams
|
||||
|
||||
with(email:, team_names:, account:, sso_provider:, uid:, name:).reduce(actions)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -7,7 +7,14 @@ module SSOProviders
|
||||
promises :configuration
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
def new
|
||||
@provider_type = params[:provider_type] || "ldap"
|
||||
@sso_provider = current_account.build_sso_provider
|
||||
@ldap_configuration = LDAPConfiguration.new
|
||||
@oidc_configuration = OIDCConfiguration.new
|
||||
end
|
||||
|
||||
def create
|
||||
provider_type = params[:provider_type] || "ldap"
|
||||
|
||||
result = SSOProviders::Create.call(
|
||||
account: current_account,
|
||||
sso_provider_params: sso_provider_params,
|
||||
configuration_params: ldap_configuration_params,
|
||||
provider_type: "ldap"
|
||||
configuration_params: configuration_params_for(provider_type),
|
||||
provider_type: provider_type
|
||||
)
|
||||
|
||||
if result.success?
|
||||
redirect_to sso_provider_path, notice: "SSO provider created successfully"
|
||||
else
|
||||
@provider_type = provider_type
|
||||
@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
|
||||
end
|
||||
end
|
||||
@@ -30,22 +36,27 @@ module Accounts
|
||||
def edit
|
||||
@sso_provider = current_account.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
|
||||
|
||||
def update
|
||||
@sso_provider = current_account.sso_provider
|
||||
provider_type = @sso_provider.oidc? ? "oidc" : "ldap"
|
||||
|
||||
result = SSOProviders::Update.call(
|
||||
sso_provider: @sso_provider,
|
||||
sso_provider_params: sso_provider_params,
|
||||
configuration_params: ldap_configuration_params
|
||||
configuration_params: configuration_params_for(provider_type)
|
||||
)
|
||||
|
||||
if result.success?
|
||||
redirect_to sso_provider_path, notice: "SSO provider updated successfully"
|
||||
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
|
||||
end
|
||||
end
|
||||
@@ -84,6 +95,17 @@ module Accounts
|
||||
params.require(:sso_provider).permit(:name, :enabled, :team_provisioning_mode)
|
||||
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
|
||||
params.require(:ldap_configuration).permit(
|
||||
:host,
|
||||
@@ -98,5 +120,21 @@ module Accounts
|
||||
:encryption
|
||||
)
|
||||
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
|
||||
|
||||
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)
|
||||
clean_up_passwords(resource)
|
||||
@sso_provider = @account.sso_provider if @account.sso_enabled?
|
||||
|
||||
if @account.sso_provider&.ldap?
|
||||
render "devise/sessions/ldap"
|
||||
elsif @account.sso_provider&.oidc?
|
||||
render "devise/sessions/oidc"
|
||||
else
|
||||
render :new
|
||||
end
|
||||
@@ -59,6 +62,9 @@ class Users::SessionsController < Devise::SessionsController
|
||||
clean_up_passwords(self.resource)
|
||||
render "devise/sessions/ldap"
|
||||
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
|
||||
redirect_to new_user_session_path
|
||||
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
|
||||
# updated_at :datetime not null
|
||||
# external_id :string
|
||||
# sso_provider_id :bigint
|
||||
# user_id :bigint not null
|
||||
#
|
||||
# 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
|
||||
#
|
||||
# fk_rails_... (sso_provider_id => sso_providers.id)
|
||||
# fk_rails_... (user_id => users.id)
|
||||
#
|
||||
class Provider < ApplicationRecord
|
||||
@@ -44,8 +48,10 @@ class Provider < ApplicationRecord
|
||||
AVAILABLE_PROVIDERS = [ GITHUB_PROVIDER, GITLAB_PROVIDER, CUSTOM_REGISTRY_PROVIDER ].freeze
|
||||
validates :registry_url, presence: true, if: :container_registry?
|
||||
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 :sso_provider, class_name: "SSOProvider", optional: true
|
||||
|
||||
Devise.omniauth_configs.keys.each do |provider|
|
||||
scope provider, -> { where(provider: provider) }
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
class SSOProvider < ApplicationRecord
|
||||
belongs_to :account
|
||||
belongs_to :configuration, polymorphic: true, dependent: :destroy
|
||||
has_many :providers, dependent: :nullify
|
||||
|
||||
validates :account_id, uniqueness: true
|
||||
|
||||
@@ -36,4 +37,12 @@ class SSOProvider < ApplicationRecord
|
||||
def ldap?
|
||||
configuration_type == "LDAPConfiguration"
|
||||
end
|
||||
|
||||
def oidc?
|
||||
configuration_type == "OIDCConfiguration"
|
||||
end
|
||||
|
||||
def sso_users_count
|
||||
providers.joins(:user).distinct.count(:user_id)
|
||||
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 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>
|
||||
|
||||
<%= form_with model: @sso_provider, url: 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="space-y-8">
|
||||
<%= render(FormFieldComponent.new(
|
||||
label: "Provider Name",
|
||||
description: "A friendly name for this SSO provider"
|
||||
)) do %>
|
||||
<%= form.text_field :name, class: "input input-bordered w-full", required: true, placeholder: "e.g., Company SSO" %>
|
||||
<label class="label">
|
||||
<span class="label-text-alt">* Required</span>
|
||||
</label>
|
||||
<% end %>
|
||||
|
||||
<div class="form-control mt-1 w-full max-w-sm">
|
||||
<label class="label cursor-pointer justify-start gap-2">
|
||||
<%= render(FormFieldComponent.new(
|
||||
label: "Enabled",
|
||||
description: "Enable or disable this SSO provider"
|
||||
)) do %>
|
||||
<%= form.check_box :enabled, class: "checkbox checkbox-primary" %>
|
||||
<span class="label-text">Enable this provider</span>
|
||||
</label>
|
||||
<% end %>
|
||||
|
||||
<%= 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 class="form-control mt-4 w-full max-w-sm">
|
||||
<label class="label">
|
||||
<span class="label-text">Team Provisioning Mode</span>
|
||||
</label>
|
||||
<%= form.select :team_provisioning_mode, SSOProvider.team_provisioning_modes.keys.map { |k| [k.titleize, k] }, {}, class: "select select-bordered" %>
|
||||
<label class="label">
|
||||
<span class="label-text-alt">How teams are provisioned for SSO users</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="divider">LDAP Configuration</div>
|
||||
|
||||
<%= render "accounts/sso_providers/ldap/form_fields", ldap_configuration: @ldap_configuration %>
|
||||
<% if @provider_type == "oidc" %>
|
||||
<div class="divider">OIDC Configuration</div>
|
||||
<%= render "accounts/sso_providers/oidc/form_fields", oidc_configuration: @oidc_configuration %>
|
||||
<% else %>
|
||||
<div class="divider">LDAP Configuration</div>
|
||||
<%= render "accounts/sso_providers/ldap/form_fields", ldap_configuration: @ldap_configuration %>
|
||||
<% end %>
|
||||
|
||||
<div class="form-footer mt-6">
|
||||
<%= form.submit "Update Provider", class: "btn btn-primary" %>
|
||||
|
||||
@@ -1,114 +1,110 @@
|
||||
<%= fields_for :ldap_configuration, ldap_configuration do |ldap_form| %>
|
||||
<div class="form-control mt-1 w-full max-w-sm">
|
||||
<label class="label">
|
||||
<span class="label-text">Host <span class="text-error">*</span></span>
|
||||
</label>
|
||||
<%= ldap_form.text_field :host, class: "input input-bordered", required: true, placeholder: "ldap.example.com" %>
|
||||
<label class="label">
|
||||
<span class="label-text-alt">LDAP server hostname or IP address</span>
|
||||
</label>
|
||||
<div class="space-y-8">
|
||||
<%= render(FormFieldComponent.new(
|
||||
label: "Host",
|
||||
description: "LDAP server hostname or IP address"
|
||||
)) do %>
|
||||
<%= ldap_form.text_field :host, class: "input input-bordered w-full", required: true, placeholder: "ldap.example.com" %>
|
||||
<label class="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 class="form-control mt-1 w-full max-w-sm">
|
||||
<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">
|
||||
<details class="collapse collapse-arrow bg-base-200 mt-8">
|
||||
<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">UID Attribute</span>
|
||||
</label>
|
||||
<%= ldap_form.text_field :uid_attribute, class: "input input-bordered", placeholder: "uid" %>
|
||||
<label class="label">
|
||||
<span class="label-text-alt">Attribute used for username (default: uid)</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="collapse-content">
|
||||
<div class="space-y-8 mt-4">
|
||||
<%= render(FormFieldComponent.new(
|
||||
label: "UID Attribute",
|
||||
description: "Attribute used for username"
|
||||
)) do %>
|
||||
<%= ldap_form.text_field :uid_attribute, class: "input input-bordered w-full", placeholder: "uid" %>
|
||||
<label class="label">
|
||||
<span class="label-text-alt">Default: uid</span>
|
||||
</label>
|
||||
<% end %>
|
||||
|
||||
<div class="form-control mt-1 w-full">
|
||||
<label class="label">
|
||||
<span class="label-text">Email Attribute</span>
|
||||
</label>
|
||||
<%= ldap_form.text_field :email_attribute, class: "input input-bordered", placeholder: "mail" %>
|
||||
<label class="label">
|
||||
<span class="label-text-alt">Attribute used for email (default: mail)</span>
|
||||
</label>
|
||||
</div>
|
||||
<%= render(FormFieldComponent.new(
|
||||
label: "Email Attribute",
|
||||
description: "Attribute used for email address"
|
||||
)) do %>
|
||||
<%= ldap_form.text_field :email_attribute, class: "input input-bordered w-full", placeholder: "mail" %>
|
||||
<label class="label">
|
||||
<span class="label-text-alt">Default: mail</span>
|
||||
</label>
|
||||
<% end %>
|
||||
|
||||
<div class="form-control mt-1 w-full">
|
||||
<label class="label">
|
||||
<span class="label-text">Name Attribute</span>
|
||||
</label>
|
||||
<%= ldap_form.text_field :name_attribute, class: "input input-bordered", placeholder: "cn" %>
|
||||
<label class="label">
|
||||
<span class="label-text-alt">Attribute used for display name (default: cn)</span>
|
||||
</label>
|
||||
</div>
|
||||
<%= render(FormFieldComponent.new(
|
||||
label: "Name Attribute",
|
||||
description: "Attribute used for display name"
|
||||
)) do %>
|
||||
<%= ldap_form.text_field :name_attribute, class: "input input-bordered w-full", placeholder: "cn" %>
|
||||
<label class="label">
|
||||
<span class="label-text-alt">Default: cn</span>
|
||||
</label>
|
||||
<% end %>
|
||||
|
||||
<div class="form-control mt-1 w-full">
|
||||
<label class="label">
|
||||
<span class="label-text">Filter</span>
|
||||
</label>
|
||||
<%= ldap_form.text_field :filter, class: "input input-bordered", placeholder: "(objectClass=person)" %>
|
||||
<label class="label">
|
||||
<span class="label-text-alt">Additional LDAP filter for user searches</span>
|
||||
</label>
|
||||
<%= render(FormFieldComponent.new(
|
||||
label: "Filter",
|
||||
description: "Additional LDAP filter for user searches"
|
||||
)) do %>
|
||||
<%= ldap_form.text_field :filter, class: "input input-bordered w-full", placeholder: "(objectClass=person)" %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="mt-6 mb-4" data-controller="ldap-test-connection">
|
||||
<div class="mt-8" data-controller="ldap-test-connection">
|
||||
<button type="button"
|
||||
class="btn btn-outline btn-sm"
|
||||
data-action="click->ldap-test-connection#test"
|
||||
|
||||
@@ -1,46 +1,58 @@
|
||||
<%= settings_layout do %>
|
||||
<%= turbo_frame_tag "sso_provider" do %>
|
||||
<div class="font-lg font-bold mt-4">
|
||||
Add LDAP Provider
|
||||
Add <%= @provider_type == "oidc" ? "OIDC" : "LDAP" %> Provider
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<%= 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 %>
|
||||
|
||||
<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="space-y-8">
|
||||
<%= render(FormFieldComponent.new(
|
||||
label: "Provider Name",
|
||||
description: "A friendly name for this SSO provider"
|
||||
)) do %>
|
||||
<%= form.text_field :name, class: "input input-bordered w-full", required: true, placeholder: "e.g., Company SSO" %>
|
||||
<label class="label">
|
||||
<span class="label-text-alt">* Required</span>
|
||||
</label>
|
||||
<% end %>
|
||||
|
||||
<div class="form-control mt-1 w-full max-w-sm">
|
||||
<label class="label cursor-pointer justify-start gap-2">
|
||||
<%= render(FormFieldComponent.new(
|
||||
label: "Enabled",
|
||||
description: "Enable or disable this SSO provider"
|
||||
)) do %>
|
||||
<%= form.check_box :enabled, class: "checkbox checkbox-primary" %>
|
||||
<span class="label-text">Enable this provider</span>
|
||||
</label>
|
||||
<% end %>
|
||||
|
||||
<%= 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 class="form-control mt-4 w-full max-w-sm">
|
||||
<label class="label">
|
||||
<span class="label-text">Team Provisioning Mode</span>
|
||||
</label>
|
||||
<%= form.select :team_provisioning_mode, SSOProvider.team_provisioning_modes.keys.map { |k| [k.titleize, k] }, {}, class: "select select-bordered" %>
|
||||
<label class="label">
|
||||
<span class="label-text-alt">How teams are provisioned for SSO users</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="divider">LDAP Configuration</div>
|
||||
|
||||
<%= render "accounts/sso_providers/ldap/form_fields", ldap_configuration: @ldap_configuration %>
|
||||
<% if @provider_type == "oidc" %>
|
||||
<div class="divider">OIDC Configuration</div>
|
||||
<%= render "accounts/sso_providers/oidc/form_fields", oidc_configuration: @oidc_configuration %>
|
||||
<% else %>
|
||||
<div class="divider">LDAP Configuration</div>
|
||||
<%= render "accounts/sso_providers/ldap/form_fields", ldap_configuration: @ldap_configuration %>
|
||||
<% end %>
|
||||
|
||||
<div class="form-footer mt-6">
|
||||
<%= 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>
|
||||
<h3 class="text-lg font-semibold"><%= @sso_provider.name %></h3>
|
||||
<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 %>
|
||||
<span class="badge badge-success">Enabled</span>
|
||||
<% else %>
|
||||
<span class="badge badge-ghost">Disabled</span>
|
||||
<% end %>
|
||||
<span class="badge badge-info"><%= @sso_provider.sso_users_count %> <%= "user".pluralize(@sso_provider.sso_users_count) %></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
@@ -26,24 +31,46 @@
|
||||
|
||||
<% if @configuration %>
|
||||
<div class="divider"></div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div class="text-sm font-medium text-base-content/70 mb-1">Host</div>
|
||||
<div class="font-mono"><%= @configuration.host %>:<%= @configuration.port %></div>
|
||||
<% if @sso_provider.ldap? %>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<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 class="text-sm font-medium text-base-content/70 mb-1">Base DN</div>
|
||||
<div class="font-mono"><%= @configuration.base_dn %></div>
|
||||
<% elsif @sso_provider.oidc? %>
|
||||
<%= render "accounts/sso_providers/oidc/redirect_uri_info" %>
|
||||
<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 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>
|
||||
@@ -54,7 +81,8 @@
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<% 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>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% current_user.providers.each do |provider| %>
|
||||
<% current_user.providers.non_sso.each do |provider| %>
|
||||
<% project_credential_providers = ProjectCredentialProvider.where(provider:).all %>
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
@@ -14,4 +14,5 @@
|
||||
ActiveSupport::Inflector.inflections(:en) do |inflect|
|
||||
inflect.acronym "SSO"
|
||||
inflect.acronym "LDAP"
|
||||
inflect.acronym "OIDC"
|
||||
end
|
||||
|
||||
@@ -5,6 +5,10 @@ Rails.application.routes.draw do
|
||||
post '/accounts/:slug/sign_in', to: 'users/sessions#account_create'
|
||||
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
|
||||
mount Avo::Engine, at: Avo.configuration.root_path
|
||||
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.
|
||||
|
||||
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
|
||||
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"
|
||||
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|
|
||||
t.bigint "project_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.string "registry_url"
|
||||
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"
|
||||
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 "projects", "clusters"
|
||||
add_foreign_key "projects", "clusters", column: "project_fork_cluster_id"
|
||||
add_foreign_key "providers", "sso_providers"
|
||||
add_foreign_key "providers", "users"
|
||||
add_foreign_key "services", "projects"
|
||||
add_foreign_key "sso_providers", "accounts"
|
||||
|
||||
@@ -8,6 +8,7 @@ x-shared-env: &shared-env
|
||||
|
||||
services:
|
||||
postgres:
|
||||
restart: unless-stopped
|
||||
image: postgres:16-bookworm@sha256:878977a5fe8d75ba7eab7610e4cf7e0c8626a683d89b3f9da965b8ceba952a09
|
||||
environment:
|
||||
- POSTGRES_USER=postgres
|
||||
@@ -17,13 +18,20 @@ services:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- "postgres:/var/lib/postgresql/data"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 30s
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
|
||||
web:
|
||||
restart: unless-stopped
|
||||
image: ghcr.io/caninehq/canine:latest
|
||||
# Overrides default command so things don't shut down after the process ends.
|
||||
# command: sleep infinity
|
||||
depends_on:
|
||||
- postgres
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
stdin_open: true
|
||||
tty: true
|
||||
ports:
|
||||
@@ -37,10 +45,12 @@ services:
|
||||
- ${DOCKER_SOCKET:-/var/run/docker.sock}:/var/run/docker.sock
|
||||
|
||||
worker:
|
||||
restart: unless-stopped
|
||||
image: ghcr.io/caninehq/canine:latest
|
||||
command: bundle exec good_job start
|
||||
depends_on:
|
||||
- postgres
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
<<: *shared-env
|
||||
LOCAL_MODE: "true"
|
||||
|
||||
@@ -8,6 +8,7 @@ x-shared-env: &shared-env
|
||||
|
||||
services:
|
||||
postgres:
|
||||
restart: unless-stopped
|
||||
image: postgres:16-bookworm@sha256:878977a5fe8d75ba7eab7610e4cf7e0c8626a683d89b3f9da965b8ceba952a09
|
||||
environment:
|
||||
- POSTGRES_USER=postgres
|
||||
@@ -17,13 +18,20 @@ services:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- "postgres:/var/lib/postgresql/data"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 30s
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
|
||||
web:
|
||||
restart: unless-stopped
|
||||
image: ghcr.io/caninehq/canine:latest
|
||||
# Overrides default command so things don't shut down after the process ends.
|
||||
# command: sleep infinity
|
||||
depends_on:
|
||||
- postgres
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
stdin_open: true
|
||||
tty: true
|
||||
ports:
|
||||
@@ -37,10 +45,12 @@ services:
|
||||
- ${DOCKER_SOCKET:-/var/run/docker.sock}:/var/run/docker.sock
|
||||
|
||||
worker:
|
||||
restart: unless-stopped
|
||||
image: ghcr.io/caninehq/canine:latest
|
||||
command: bundle exec good_job start
|
||||
depends_on:
|
||||
- postgres
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
<<: *shared-env
|
||||
LOCAL_MODE: "true"
|
||||
|
||||
@@ -51,17 +51,15 @@ module Devise
|
||||
|
||||
email = result.email
|
||||
|
||||
if sso_provider.just_in_time_team_provisioning_mode?
|
||||
groups = result.groups
|
||||
ar_result = ActiveRecord::Base.transaction do
|
||||
SSO::SyncUserTeams.call(email, groups, ldap_configuration.account)
|
||||
end
|
||||
else
|
||||
ar_result = SSO::CreateUserInAccount.execute(
|
||||
email: email,
|
||||
account: ldap_configuration.account,
|
||||
)
|
||||
end
|
||||
ar_result = SSO::SyncUserTeams.call(
|
||||
email: email,
|
||||
team_names: result.groups || [],
|
||||
account: ldap_configuration.account,
|
||||
sso_provider: sso_provider,
|
||||
uid: result.user_dn,
|
||||
name: result.name,
|
||||
create_teams: sso_provider.just_in_time_team_provisioning_mode?
|
||||
)
|
||||
|
||||
if ar_result.failure?
|
||||
return fail(:invalid_login)
|
||||
|
||||
@@ -2,26 +2,101 @@ require 'rails_helper'
|
||||
|
||||
RSpec.describe SSO::CreateUserInAccount do
|
||||
let(:account) { create(:account) }
|
||||
let(:sso_provider) { create(:sso_provider, account: account) }
|
||||
|
||||
describe '.execute' do
|
||||
it 'creates a new user and associates them with the account when user does not exist' do
|
||||
result = described_class.execute(email: 'new@example.com', account: account)
|
||||
it 'creates a new user, provider record, and associates them with the account' do
|
||||
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.user).to be_persisted
|
||||
expect(result.user.email).to eq('new@example.com')
|
||||
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
|
||||
|
||||
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')
|
||||
|
||||
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.user).to eq(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
|
||||
|
||||
@@ -2,12 +2,20 @@ require 'rails_helper'
|
||||
|
||||
RSpec.describe SSO::SyncUserTeams do
|
||||
let(:account) { create(:account) }
|
||||
let(:sso_provider) { create(:sso_provider, account: account) }
|
||||
|
||||
describe '.call' do
|
||||
it 'creates user, teams, and team memberships' do
|
||||
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.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_remove)
|
||||
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.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_other)
|
||||
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(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
|
||||
# updated_at :datetime not null
|
||||
# external_id :string
|
||||
# sso_provider_id :bigint
|
||||
# user_id :bigint not null
|
||||
#
|
||||
# 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
|
||||
#
|
||||
# fk_rails_... (sso_provider_id => sso_providers.id)
|
||||
# fk_rails_... (user_id => users.id)
|
||||
#
|
||||
FactoryBot.define do
|
||||
|
||||
@@ -23,9 +23,10 @@
|
||||
#
|
||||
FactoryBot.define do
|
||||
factory :sso_provider do
|
||||
account { nil }
|
||||
configuration { nil }
|
||||
name { "MyString" }
|
||||
enabled { false }
|
||||
account
|
||||
configuration { association :oidc_configuration }
|
||||
name { "Test SSO Provider" }
|
||||
enabled { true }
|
||||
team_provisioning_mode { :disabled }
|
||||
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'
|
||||
|
||||
RSpec.describe Provider, type: :model do
|
||||
|
||||
@@ -24,5 +24,28 @@
|
||||
require 'rails_helper'
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user