mirror of
https://github.com/czhu12/canine.git
synced 2025-12-17 17:04:37 -06:00
update form update fields
This commit is contained in:
2
Gemfile
2
Gemfile
@@ -116,3 +116,5 @@ gem 'flipper-active_record', '~> 1.2.2'
|
|||||||
gem 'flipper-ui', '~> 1.2.2'
|
gem 'flipper-ui', '~> 1.2.2'
|
||||||
|
|
||||||
gem "net-ldap", "~> 0.20.0"
|
gem "net-ldap", "~> 0.20.0"
|
||||||
|
|
||||||
|
gem "jwt", "~> 2.9"
|
||||||
|
|||||||
@@ -723,6 +723,7 @@ DEPENDENCIES
|
|||||||
importmap-rails
|
importmap-rails
|
||||||
jbuilder
|
jbuilder
|
||||||
jsbundling-rails
|
jsbundling-rails
|
||||||
|
jwt (~> 2.9)
|
||||||
k8s-ruby (~> 0.17.2)
|
k8s-ruby (~> 0.17.2)
|
||||||
kubeclient (~> 4.12)
|
kubeclient (~> 4.12)
|
||||||
light-service (~> 0.20.0)
|
light-service (~> 0.20.0)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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?
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -24,5 +24,28 @@
|
|||||||
require 'rails_helper'
|
require 'rails_helper'
|
||||||
|
|
||||||
RSpec.describe SSOProvider, type: :model do
|
RSpec.describe SSOProvider, type: :model do
|
||||||
pending "add some examples to (or delete) #{__FILE__}"
|
describe '#sso_users_count' do
|
||||||
|
let(:sso_provider) { create(:sso_provider) }
|
||||||
|
|
||||||
|
it 'returns 0 when no users have signed in' do
|
||||||
|
expect(sso_provider.sso_users_count).to eq(0)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'counts users who have signed in via SSO' do
|
||||||
|
user1 = create(:user)
|
||||||
|
user2 = create(:user)
|
||||||
|
create(:provider, user: user1, sso_provider: sso_provider, uid: 'uid-1', provider: sso_provider.name)
|
||||||
|
create(:provider, user: user2, sso_provider: sso_provider, uid: 'uid-2', provider: sso_provider.name)
|
||||||
|
|
||||||
|
expect(sso_provider.sso_users_count).to eq(2)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not count users from other SSO providers' do
|
||||||
|
other_sso_provider = create(:sso_provider, account: create(:account))
|
||||||
|
user = create(:user)
|
||||||
|
create(:provider, user: user, sso_provider: other_sso_provider, uid: 'uid-1', provider: other_sso_provider.name)
|
||||||
|
|
||||||
|
expect(sso_provider.sso_users_count).to eq(0)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
Reference in New Issue
Block a user