diff --git a/Gemfile b/Gemfile index 0a958770..2ef8b9c7 100644 --- a/Gemfile +++ b/Gemfile @@ -116,3 +116,5 @@ gem 'flipper-active_record', '~> 1.2.2' gem 'flipper-ui', '~> 1.2.2' gem "net-ldap", "~> 0.20.0" + +gem "jwt", "~> 2.9" diff --git a/Gemfile.lock b/Gemfile.lock index 3f80664f..e9d60357 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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) diff --git a/app/actions/sso/create_user_in_account.rb b/app/actions/sso/create_user_in_account.rb index 0b62fb18..c30fb570 100644 --- a/app/actions/sso/create_user_in_account.rb +++ b/app/actions/sso/create_user_in_account.rb @@ -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) diff --git a/app/actions/sso/sync_user_teams.rb b/app/actions/sso/sync_user_teams.rb index e0e2d33e..a65ce24d 100644 --- a/app/actions/sso/sync_user_teams.rb +++ b/app/actions/sso/sync_user_teams.rb @@ -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 diff --git a/app/actions/sso_providers/build_sso_configuration.rb b/app/actions/sso_providers/build_sso_configuration.rb index 0a1eb602..985d2f1e 100644 --- a/app/actions/sso_providers/build_sso_configuration.rb +++ b/app/actions/sso_providers/build_sso_configuration.rb @@ -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 diff --git a/app/avo/resources/oidc_configuration.rb b/app/avo/resources/oidc_configuration.rb new file mode 100644 index 00000000..3a4d2cb7 --- /dev/null +++ b/app/avo/resources/oidc_configuration.rb @@ -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 diff --git a/app/controllers/accounts/oidc_controller.rb b/app/controllers/accounts/oidc_controller.rb new file mode 100644 index 00000000..86603877 --- /dev/null +++ b/app/controllers/accounts/oidc_controller.rb @@ -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 diff --git a/app/controllers/accounts/sso_providers_controller.rb b/app/controllers/accounts/sso_providers_controller.rb index b2b39dff..de0b2e96 100644 --- a/app/controllers/accounts/sso_providers_controller.rb +++ b/app/controllers/accounts/sso_providers_controller.rb @@ -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 diff --git a/app/controllers/avo/oidc_configurations_controller.rb b/app/controllers/avo/oidc_configurations_controller.rb new file mode 100644 index 00000000..08b8968b --- /dev/null +++ b/app/controllers/avo/oidc_configurations_controller.rb @@ -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 diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb index a881a9b1..b1848487 100644 --- a/app/controllers/users/sessions_controller.rb +++ b/app/controllers/users/sessions_controller.rb @@ -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 diff --git a/app/models/oidc_configuration.rb b/app/models/oidc_configuration.rb new file mode 100644 index 00000000..13875680 --- /dev/null +++ b/app/models/oidc_configuration.rb @@ -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 diff --git a/app/models/provider.rb b/app/models/provider.rb index 8cb7fa23..16fd3f8c 100644 --- a/app/models/provider.rb +++ b/app/models/provider.rb @@ -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) } diff --git a/app/models/sso_provider.rb b/app/models/sso_provider.rb index 51311213..fc8c47f5 100644 --- a/app/models/sso_provider.rb +++ b/app/models/sso_provider.rb @@ -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 diff --git a/app/services/oidc/authenticator.rb b/app/services/oidc/authenticator.rb new file mode 100644 index 00000000..9c30b4bc --- /dev/null +++ b/app/services/oidc/authenticator.rb @@ -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 diff --git a/app/views/accounts/sso_providers/edit.html.erb b/app/views/accounts/sso_providers/edit.html.erb index 5ff26094..130bebc1 100644 --- a/app/views/accounts/sso_providers/edit.html.erb +++ b/app/views/accounts/sso_providers/edit.html.erb @@ -5,42 +5,49 @@