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..9cd2501d --- /dev/null +++ b/app/controllers/accounts/oidc_controller.rb @@ -0,0 +1,111 @@ +module Accounts + class OIDCController < ApplicationController + skip_before_action :authenticate_user! + before_action :load_account + + def authorize + oidc_config = @account.sso_provider&.configuration + unless oidc_config.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 + auth_url = build_authorization_url(oidc_config, state) + redirect_to auth_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_config = @account.sso_provider&.configuration + + unless oidc_config.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_config).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 + if sso_provider.just_in_time_team_provisioning_mode? + ar_result = ActiveRecord::Base.transaction do + SSO::SyncUserTeams.call(result.email, result.groups || [], @account) + end + else + ar_result = SSO::CreateUserInAccount.execute( + email: result.email, + account: @account + ) + end + + 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_config, state) + params = { + response_type: "code", + client_id: oidc_config.client_id, + redirect_uri: oidc_callback_url(slug: @account.slug), + scope: oidc_config.scopes, + state: state + } + + auth_endpoint = oidc_config.authorization_endpoint.presence || discover_authorization_endpoint(oidc_config) + "#{auth_endpoint}?#{params.to_query}" + end + + def discover_authorization_endpoint(oidc_config) + # Fetch from OIDC discovery document + discovery_url = oidc_config.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..65bdc47d --- /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 + "#{issuer.chomp('/')}/.well-known/openid-configuration" + end + + def uses_discovery? + authorization_endpoint.blank? && token_endpoint.blank? + end +end diff --git a/app/models/sso_provider.rb b/app/models/sso_provider.rb index 51311213..ed1cbfac 100644 --- a/app/models/sso_provider.rb +++ b/app/models/sso_provider.rb @@ -36,4 +36,8 @@ class SSOProvider < ApplicationRecord def ldap? configuration_type == "LDAPConfiguration" end + + def oidc? + configuration_type == "OIDCConfiguration" + end end diff --git a/app/services/oidc/authenticator.rb b/app/services/oidc/authenticator.rb new file mode 100644 index 00000000..40bd09f3 --- /dev/null +++ b/app/services/oidc/authenticator.rb @@ -0,0 +1,147 @@ +# 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? + # 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 + 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) + # 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 + + 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}") + 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 @@