Merge pull request #456 from CanineHQ/chriszhu__oidc

added oidc impementation
This commit is contained in:
Chris Zhu
2025-12-13 09:20:07 +09:00
committed by GitHub
38 changed files with 1190 additions and 225 deletions

View File

@@ -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"

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View 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

View 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

View File

@@ -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

View File

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

View File

@@ -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

View 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

View File

@@ -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) }

View File

@@ -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

View 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

View File

@@ -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" %>

View File

@@ -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"

View File

@@ -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" %>

View 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 %>

View File

@@ -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>

View File

@@ -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 %>

View 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>

View File

@@ -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>

View File

@@ -14,4 +14,5 @@
ActiveSupport::Inflector.inflections(:en) do |inflect|
inflect.acronym "SSO"
inflect.acronym "LDAP"
inflect.acronym "OIDC"
end

View File

@@ -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

View 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

View File

@@ -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
View File

@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2025_11_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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View 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

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -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