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

@@ -1,10 +1,11 @@
class SSO::SyncUserTeams
extend LightService::Organizer
def self.call(email:, team_names:, account:, sso_provider:, uid:, name: nil)
with(email:, team_names:, account:, sso_provider:, uid:, name:).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

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

View File

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

View File

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

View File

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

View File

@@ -54,7 +54,7 @@
label: "Bind Password",
description: "Password for the bind DN"
)) 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 %>
</div>

View File

@@ -26,7 +26,7 @@
label: "Client Secret",
description: "OAuth 2.0 client secret"
)) 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">
<span class="label-text-alt">* Required</span>
</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>
<div>
<div class="font-medium">Redirect URI</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>

View File

@@ -20,6 +20,7 @@
<% 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">

View File

@@ -51,27 +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: email,
team_names: groups,
account: ldap_configuration.account,
sso_provider: sso_provider,
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
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

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

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