update form update fields

This commit is contained in:
Chris
2025-12-12 15:22:19 -08:00
parent 165f234dd8
commit f8ce23f116
14 changed files with 109 additions and 78 deletions

View File

@@ -116,3 +116,5 @@ gem 'flipper-active_record', '~> 1.2.2'
gem 'flipper-ui', '~> 1.2.2' gem 'flipper-ui', '~> 1.2.2'
gem "net-ldap", "~> 0.20.0" gem "net-ldap", "~> 0.20.0"
gem "jwt", "~> 2.9"

View File

@@ -723,6 +723,7 @@ DEPENDENCIES
importmap-rails importmap-rails
jbuilder jbuilder
jsbundling-rails jsbundling-rails
jwt (~> 2.9)
k8s-ruby (~> 0.17.2) k8s-ruby (~> 0.17.2)
kubeclient (~> 4.12) kubeclient (~> 4.12)
light-service (~> 0.20.0) light-service (~> 0.20.0)

View File

@@ -1,10 +1,11 @@
class SSO::SyncUserTeams class SSO::SyncUserTeams
extend LightService::Organizer extend LightService::Organizer
def self.call(email:, team_names:, account:, sso_provider:, uid:, name: nil) def self.call(email:, team_names:, account:, sso_provider:, uid:, name: nil, create_teams: false)
with(email:, team_names:, account:, sso_provider:, uid:, name:).reduce( actions = []
SSO::CreateTeamsInAccount, actions << SSO::CreateTeamsInAccount if create_teams
SSO::CreateUserInAccount, actions << SSO::CreateUserInAccount
SSO::SyncTeams, actions << SSO::SyncTeams if create_teams
)
with(email:, team_names:, account:, sso_provider:, uid:, name:).reduce(actions)
end end
end end

View File

@@ -4,8 +4,8 @@ module Accounts
before_action :load_account before_action :load_account
def authorize def authorize
oidc_config = @account.sso_provider&.configuration oidc_configuration = @account.sso_provider&.configuration
unless oidc_config.is_a?(OIDCConfiguration) unless oidc_configuration.is_a?(OIDCConfiguration)
redirect_to account_sign_in_path(@account.slug), alert: "OIDC is not configured for this account" redirect_to account_sign_in_path(@account.slug), alert: "OIDC is not configured for this account"
return return
end end
@@ -15,8 +15,8 @@ module Accounts
session[:oidc_state] = state session[:oidc_state] = state
# Build authorization URL # Build authorization URL
auth_url = build_authorization_url(oidc_config, state) authorization_url = build_authorization_url(oidc_configuration, state)
redirect_to auth_url, allow_other_host: true redirect_to authorization_url, allow_other_host: true
end end
def callback def callback
@@ -26,9 +26,9 @@ module Accounts
return return
end end
oidc_config = @account.sso_provider&.configuration oidc_configuration = @account.sso_provider&.configuration
unless oidc_config.is_a?(OIDCConfiguration) unless oidc_configuration.is_a?(OIDCConfiguration)
redirect_to root_path, alert: "OIDC is not configured" redirect_to root_path, alert: "OIDC is not configured"
return return
end end
@@ -39,7 +39,7 @@ module Accounts
end end
# Exchange code for tokens # Exchange code for tokens
result = OIDC::Authenticator.new(oidc_config).authenticate( result = OIDC::Authenticator.new(oidc_configuration).authenticate(
code: params[:code], code: params[:code],
redirect_uri: oidc_callback_url(slug: @account.slug) redirect_uri: oidc_callback_url(slug: @account.slug)
) )
@@ -51,26 +51,15 @@ module Accounts
# Create or find user # Create or find user
sso_provider = @account.sso_provider sso_provider = @account.sso_provider
if sso_provider.just_in_time_team_provisioning_mode? ar_result = SSO::SyncUserTeams.call(
ar_result = ActiveRecord::Base.transaction do email: result.email,
SSO::SyncUserTeams.call( team_names: result.groups || [],
email: result.email, account: @account,
team_names: result.groups || [], sso_provider: sso_provider,
account: @account, uid: result.uid,
sso_provider: sso_provider, name: result.name,
uid: result.uid, create_teams: sso_provider.just_in_time_team_provisioning_mode?
name: result.name )
)
end
else
ar_result = SSO::CreateUserInAccount.execute(
email: result.email,
account: @account,
sso_provider: sso_provider,
uid: result.uid,
name: result.name
)
end
if ar_result.failure? if ar_result.failure?
redirect_to account_sign_in_path(@account.slug), alert: "Failed to create user account" redirect_to account_sign_in_path(@account.slug), alert: "Failed to create user account"
@@ -94,22 +83,22 @@ module Accounts
redirect_to root_path, alert: "Account not found" redirect_to root_path, alert: "Account not found"
end end
def build_authorization_url(oidc_config, state) def build_authorization_url(oidc_configuration, state)
params = { params = {
response_type: "code", response_type: "code",
client_id: oidc_config.client_id, client_id: oidc_configuration.client_id,
redirect_uri: oidc_callback_url(slug: @account.slug), redirect_uri: oidc_callback_url(slug: @account.slug),
scope: oidc_config.scopes, scope: oidc_configuration.scopes,
state: state state: state
} }
auth_endpoint = oidc_config.authorization_endpoint.presence || discover_authorization_endpoint(oidc_config) auth_endpoint = oidc_configuration.authorization_endpoint.presence || discover_authorization_endpoint(oidc_configuration)
"#{auth_endpoint}?#{params.to_query}" "#{auth_endpoint}?#{params.to_query}"
end end
def discover_authorization_endpoint(oidc_config) def discover_authorization_endpoint(oidc_configuration)
# Fetch from OIDC discovery document # Fetch from OIDC discovery document
discovery_url = oidc_config.discovery_url discovery_url = oidc_configuration.discovery_url
response = HTTP.get(discovery_url) response = HTTP.get(discovery_url)
if response.status.success? if response.status.success?
JSON.parse(response.body.to_s)["authorization_endpoint"] JSON.parse(response.body.to_s)["authorization_endpoint"]

View File

@@ -27,7 +27,7 @@ class OIDCConfiguration < ApplicationRecord
validates :uid_claim, presence: true validates :uid_claim, presence: true
def discovery_url def discovery_url
"#{issuer.chomp('/')}/.well-known/openid-configuration" URI.join(issuer, ".well-known/openid-configuration").to_s
end end
def uses_discovery? def uses_discovery?

View File

@@ -41,4 +41,8 @@ class SSOProvider < ApplicationRecord
def oidc? def oidc?
configuration_type == "OIDCConfiguration" configuration_type == "OIDCConfiguration"
end end
def sso_users_count
providers.joins(:user).distinct.count(:user_id)
end
end end

View File

@@ -86,8 +86,6 @@ module OIDC
def extract_claims(id_token, access_token) def extract_claims(id_token, access_token)
if id_token.present? if id_token.present?
# Decode JWT without verification for now (verification should be added for production)
# The ID token contains the user claims
payload = decode_jwt(id_token) payload = decode_jwt(id_token)
return payload if payload.is_a?(Result) return payload if payload.is_a?(Result)
payload payload
@@ -100,14 +98,32 @@ module OIDC
end end
def decode_jwt(token) def decode_jwt(token)
# Simple JWT decode (without signature verification - should add JWKS verification for production) jwks = fetch_jwks
parts = token.split(".") decoded = JWT.decode(token, nil, true, {
return Result.new(success?: false, error_message: "Invalid JWT format") if parts.length < 2 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
payload = Base64.urlsafe_decode64(parts[1] + "=" * (4 - parts[1].length % 4)) def fetch_jwks
JSON.parse(payload) return @jwks_cache if @jwks_cache
rescue => e
Result.new(success?: false, error_message: "Failed to decode JWT: #{e.message}") 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 end
def fetch_userinfo(access_token) def fetch_userinfo(access_token)

View File

@@ -54,7 +54,7 @@
label: "Bind Password", label: "Bind Password",
description: "Password for the bind DN" description: "Password for the bind DN"
)) do %> )) do %>
<%= ldap_form.password_field :bind_password, class: "input input-bordered w-full" %> <%= ldap_form.text_field :bind_password, type: "password", class: "input input-bordered w-full" %>
<% end %> <% end %>
</div> </div>

View File

@@ -26,7 +26,7 @@
label: "Client Secret", label: "Client Secret",
description: "OAuth 2.0 client secret" description: "OAuth 2.0 client secret"
)) do %> )) do %>
<%= oidc_form.password_field :client_secret, class: "input input-bordered w-full", required: true %> <%= oidc_form.text_field :client_secret, type: "password", class: "input input-bordered w-full", required: true %>
<label class="label"> <label class="label">
<span class="label-text-alt">* Required</span> <span class="label-text-alt">* Required</span>
</label> </label>

View File

@@ -1,8 +1,11 @@
<div class="alert mb-4"> <div class="alert alert-warning mb-4">
<iconify-icon icon="lucide:info" class="mr-2"></iconify-icon> <iconify-icon icon="lucide:info" class="mr-2"></iconify-icon>
<div> <div>
<div class="font-medium">Redirect URI</div> <div class="font-medium">Redirect URI</div>
<div class="text-sm mt-1">Configure this redirect URI in your OIDC provider:</div> <div class="text-sm mt-1">Configure this redirect URI in your OIDC provider:</div>
<code class="block mt-2 text-xs bg-base-300 p-2 rounded break-all select-all"><%= oidc_callback_url(slug: current_account.slug) %></code> <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>
</div> </div>

View File

@@ -20,6 +20,7 @@
<% else %> <% else %>
<span class="badge badge-ghost">Disabled</span> <span class="badge badge-ghost">Disabled</span>
<% end %> <% end %>
<span class="badge badge-info"><%= @sso_provider.sso_users_count %> <%= "user".pluralize(@sso_provider.sso_users_count) %></span>
</div> </div>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">

View File

@@ -51,27 +51,15 @@ module Devise
email = result.email email = result.email
if sso_provider.just_in_time_team_provisioning_mode? ar_result = SSO::SyncUserTeams.call(
groups = result.groups email: email,
ar_result = ActiveRecord::Base.transaction do team_names: result.groups || [],
SSO::SyncUserTeams.call( account: ldap_configuration.account,
email: email, sso_provider: sso_provider,
team_names: groups, uid: result.user_dn,
account: ldap_configuration.account, name: result.name,
sso_provider: sso_provider, create_teams: sso_provider.just_in_time_team_provisioning_mode?
uid: result.user_dn, )
name: result.name
)
end
else
ar_result = SSO::CreateUserInAccount.execute(
email: email,
account: ldap_configuration.account,
sso_provider: sso_provider,
uid: result.user_dn,
name: result.name
)
end
if ar_result.failure? if ar_result.failure?
return fail(:invalid_login) return fail(:invalid_login)

View File

@@ -13,7 +13,8 @@ RSpec.describe SSO::SyncUserTeams do
team_names: [ { name: 'Existing' }, { name: 'NewTeam' } ], team_names: [ { name: 'Existing' }, { name: 'NewTeam' } ],
account: account, account: account,
sso_provider: sso_provider, sso_provider: sso_provider,
uid: 'user-uid-123' uid: 'user-uid-123',
create_teams: true
) )
expect(result).to be_success expect(result).to be_success
@@ -36,7 +37,8 @@ RSpec.describe SSO::SyncUserTeams do
team_names: [ { name: 'KeepTeam' } ], team_names: [ { name: 'KeepTeam' } ],
account: account, account: account,
sso_provider: sso_provider, sso_provider: sso_provider,
uid: 'existing-uid' uid: 'existing-uid',
create_teams: true
) )
expect(result).to be_success expect(result).to be_success
@@ -58,7 +60,8 @@ RSpec.describe SSO::SyncUserTeams do
team_names: [], team_names: [],
account: account, account: account,
sso_provider: sso_provider, sso_provider: sso_provider,
uid: 'existing-uid' uid: 'existing-uid',
create_teams: true
) )
expect(result).to be_success expect(result).to be_success

View File

@@ -24,5 +24,28 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe SSOProvider, type: :model do RSpec.describe SSOProvider, type: :model do
pending "add some examples to (or delete) #{__FILE__}" describe '#sso_users_count' do
let(:sso_provider) { create(:sso_provider) }
it 'returns 0 when no users have signed in' do
expect(sso_provider.sso_users_count).to eq(0)
end
it 'counts users who have signed in via SSO' do
user1 = create(:user)
user2 = create(:user)
create(:provider, user: user1, sso_provider: sso_provider, uid: 'uid-1', provider: sso_provider.name)
create(:provider, user: user2, sso_provider: sso_provider, uid: 'uid-2', provider: sso_provider.name)
expect(sso_provider.sso_users_count).to eq(2)
end
it 'does not count users from other SSO providers' do
other_sso_provider = create(:sso_provider, account: create(:account))
user = create(:user)
create(:provider, user: user, sso_provider: other_sso_provider, uid: 'uid-1', provider: other_sso_provider.name)
expect(sso_provider.sso_users_count).to eq(0)
end
end
end end