added oidc impementation

This commit is contained in:
Chris
2025-12-12 12:59:47 -08:00
parent 7aaa52828e
commit d302172904
25 changed files with 953 additions and 184 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -36,4 +36,8 @@ class SSOProvider < ApplicationRecord
def ldap?
configuration_type == "LDAPConfiguration"
end
def oidc?
configuration_type == "OIDCConfiguration"
end
end

View File

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

View File

@@ -5,42 +5,49 @@
</div>
<div class="text-sm text-gray-500 mt-2 mb-4">
Update your LDAP provider configuration.
<% if @provider_type == "oidc" %>
Update your OIDC provider configuration.
<% else %>
Update your LDAP provider configuration.
<% end %>
</div>
<%= form_with model: @sso_provider, url: sso_provider_path do |form| %>
<%= render "shared/error_messages", resource: form.object %>
<div class="form-control mt-4 w-full max-w-sm">
<label class="label">
<span class="label-text">Provider Name</span>
</label>
<%= form.text_field :name, class: "input input-bordered", required: true, placeholder: "e.g., Company SSO" %>
<label class="label">
<span class="label-text-alt">A friendly name for this SSO provider</span>
</label>
</div>
<div class="space-y-8">
<%= render(FormFieldComponent.new(
label: "Provider Name",
description: "A friendly name for this SSO provider"
)) do %>
<%= form.text_field :name, class: "input input-bordered w-full", required: true, placeholder: "e.g., Company SSO" %>
<label class="label">
<span class="label-text-alt">* Required</span>
</label>
<% end %>
<div class="form-control mt-1 w-full max-w-sm">
<label class="label cursor-pointer justify-start gap-2">
<%= render(FormFieldComponent.new(
label: "Enabled",
description: "Enable or disable this SSO provider"
)) do %>
<%= form.check_box :enabled, class: "checkbox checkbox-primary" %>
<span class="label-text">Enable this provider</span>
</label>
<% end %>
<%= render(FormFieldComponent.new(
label: "Team Provisioning Mode",
description: "How teams are provisioned for SSO users"
)) do %>
<%= form.select :team_provisioning_mode, SSOProvider.team_provisioning_modes.keys.map { |k| [k.titleize, k] }, {}, class: "select select-bordered w-full" %>
<% end %>
</div>
<div class="form-control mt-4 w-full max-w-sm">
<label class="label">
<span class="label-text">Team Provisioning Mode</span>
</label>
<%= form.select :team_provisioning_mode, SSOProvider.team_provisioning_modes.keys.map { |k| [k.titleize, k] }, {}, class: "select select-bordered" %>
<label class="label">
<span class="label-text-alt">How teams are provisioned for SSO users</span>
</label>
</div>
<div class="divider">LDAP Configuration</div>
<%= render "accounts/sso_providers/ldap/form_fields", ldap_configuration: @ldap_configuration %>
<% if @provider_type == "oidc" %>
<div class="divider">OIDC Configuration</div>
<%= render "accounts/sso_providers/oidc/form_fields", oidc_configuration: @oidc_configuration %>
<% else %>
<div class="divider">LDAP Configuration</div>
<%= render "accounts/sso_providers/ldap/form_fields", ldap_configuration: @ldap_configuration %>
<% end %>
<div class="form-footer mt-6">
<%= form.submit "Update Provider", class: "btn btn-primary" %>

View File

@@ -1,114 +1,110 @@
<%= fields_for :ldap_configuration, ldap_configuration do |ldap_form| %>
<div class="form-control mt-1 w-full max-w-sm">
<label class="label">
<span class="label-text">Host <span class="text-error">*</span></span>
</label>
<%= ldap_form.text_field :host, class: "input input-bordered", required: true, placeholder: "ldap.example.com" %>
<label class="label">
<span class="label-text-alt">LDAP server hostname or IP address</span>
</label>
<div class="space-y-8">
<%= render(FormFieldComponent.new(
label: "Host",
description: "LDAP server hostname or IP address"
)) do %>
<%= ldap_form.text_field :host, class: "input input-bordered w-full", required: true, placeholder: "ldap.example.com" %>
<label class="label">
<span class="label-text-alt">* Required</span>
</label>
<% end %>
<%= render(FormFieldComponent.new(
label: "Port",
description: "LDAP server port"
)) do %>
<%= ldap_form.number_field :port, class: "input input-bordered w-full", required: true, placeholder: "389" %>
<label class="label">
<span class="label-text-alt">Default: 389 (LDAP), 636 (LDAPS)</span>
</label>
<% end %>
<%= render(FormFieldComponent.new(
label: "Base DN",
description: "Base Distinguished Name for user searches"
)) do %>
<%= ldap_form.text_field :base_dn, class: "input input-bordered w-full", required: true, placeholder: "dc=example,dc=com" %>
<label class="label">
<span class="label-text-alt">* Required</span>
</label>
<% end %>
<%= render(FormFieldComponent.new(
label: "Encryption",
description: "Connection encryption method"
)) do %>
<%= ldap_form.select :encryption,
LDAPConfiguration.encryptions.keys.map { |key| [key.titleize, key] },
{},
class: "select select-bordered w-full" %>
<% end %>
<%= render(FormFieldComponent.new(
label: "Bind DN",
description: "Service account DN for LDAP searches"
)) do %>
<%= ldap_form.text_field :bind_dn, class: "input input-bordered w-full", placeholder: "cn=admin,dc=example,dc=com" %>
<label class="label">
<span class="label-text-alt">Leave empty for anonymous bind</span>
</label>
<% end %>
<%= render(FormFieldComponent.new(
label: "Bind Password",
description: "Password for the bind DN"
)) do %>
<%= ldap_form.password_field :bind_password, class: "input input-bordered w-full" %>
<% end %>
</div>
<div class="form-control mt-1 w-full max-w-sm">
<label class="label">
<span class="label-text">Port <span class="text-error">*</span></span>
</label>
<%= ldap_form.number_field :port, class: "input input-bordered", required: true, placeholder: "389" %>
<label class="label">
<span class="label-text-alt">Default: 389 (LDAP), 636 (LDAPS)</span>
</label>
</div>
<div class="form-control mt-1 w-full max-w-sm">
<label class="label">
<span class="label-text">Base DN <span class="text-error">*</span></span>
</label>
<%= ldap_form.text_field :base_dn, class: "input input-bordered", required: true, placeholder: "dc=example,dc=com" %>
<label class="label">
<span class="label-text-alt">Base Distinguished Name for user searches</span>
</label>
</div>
<div class="form-control mt-1 w-full max-w-sm">
<label class="label">
<span class="label-text">Encryption</span>
</label>
<%= ldap_form.select :encryption,
LDAPConfiguration.encryptions.keys.map { |key| [key.titleize, key] },
{},
class: "select select-bordered" %>
<label class="label">
<span class="label-text-alt">Connection encryption method</span>
</label>
</div>
<div class="divider">Bind Credentials (Optional)</div>
<div class="form-control mt-1 w-full max-w-sm">
<label class="label">
<span class="label-text">Bind DN</span>
</label>
<%= ldap_form.text_field :bind_dn, class: "input input-bordered", placeholder: "cn=admin,dc=example,dc=com" %>
<label class="label">
<span class="label-text-alt">Leave empty for anonymous bind</span>
</label>
</div>
<div class="form-control mt-1 w-full max-w-sm">
<label class="label">
<span class="label-text">Bind Password</span>
</label>
<%= ldap_form.password_field :bind_password, class: "input input-bordered" %>
</div>
<details class="collapse collapse-arrow bg-base-200 mt-4 max-w-sm">
<details class="collapse collapse-arrow bg-base-200 mt-8">
<summary class="collapse-title font-medium">
Advanced Settings (Optional)
</summary>
<div class="collapse-content space-y-4">
<div class="form-control mt-1 w-full">
<label class="label">
<span class="label-text">UID Attribute</span>
</label>
<%= ldap_form.text_field :uid_attribute, class: "input input-bordered", placeholder: "uid" %>
<label class="label">
<span class="label-text-alt">Attribute used for username (default: uid)</span>
</label>
</div>
<div class="collapse-content">
<div class="space-y-8 mt-4">
<%= render(FormFieldComponent.new(
label: "UID Attribute",
description: "Attribute used for username"
)) do %>
<%= ldap_form.text_field :uid_attribute, class: "input input-bordered w-full", placeholder: "uid" %>
<label class="label">
<span class="label-text-alt">Default: uid</span>
</label>
<% end %>
<div class="form-control mt-1 w-full">
<label class="label">
<span class="label-text">Email Attribute</span>
</label>
<%= ldap_form.text_field :email_attribute, class: "input input-bordered", placeholder: "mail" %>
<label class="label">
<span class="label-text-alt">Attribute used for email (default: mail)</span>
</label>
</div>
<%= render(FormFieldComponent.new(
label: "Email Attribute",
description: "Attribute used for email address"
)) do %>
<%= ldap_form.text_field :email_attribute, class: "input input-bordered w-full", placeholder: "mail" %>
<label class="label">
<span class="label-text-alt">Default: mail</span>
</label>
<% end %>
<div class="form-control mt-1 w-full">
<label class="label">
<span class="label-text">Name Attribute</span>
</label>
<%= ldap_form.text_field :name_attribute, class: "input input-bordered", placeholder: "cn" %>
<label class="label">
<span class="label-text-alt">Attribute used for display name (default: cn)</span>
</label>
</div>
<%= render(FormFieldComponent.new(
label: "Name Attribute",
description: "Attribute used for display name"
)) do %>
<%= ldap_form.text_field :name_attribute, class: "input input-bordered w-full", placeholder: "cn" %>
<label class="label">
<span class="label-text-alt">Default: cn</span>
</label>
<% end %>
<div class="form-control mt-1 w-full">
<label class="label">
<span class="label-text">Filter</span>
</label>
<%= ldap_form.text_field :filter, class: "input input-bordered", placeholder: "(objectClass=person)" %>
<label class="label">
<span class="label-text-alt">Additional LDAP filter for user searches</span>
</label>
<%= render(FormFieldComponent.new(
label: "Filter",
description: "Additional LDAP filter for user searches"
)) do %>
<%= ldap_form.text_field :filter, class: "input input-bordered w-full", placeholder: "(objectClass=person)" %>
<% end %>
</div>
</div>
</details>
<div class="mt-6 mb-4" data-controller="ldap-test-connection">
<div class="mt-8" data-controller="ldap-test-connection">
<button type="button"
class="btn btn-outline btn-sm"
data-action="click->ldap-test-connection#test"

View File

@@ -1,46 +1,58 @@
<%= settings_layout do %>
<%= turbo_frame_tag "sso_provider" do %>
<div class="font-lg font-bold mt-4">
Add LDAP Provider
Add <%= @provider_type == "oidc" ? "OIDC" : "LDAP" %> Provider
</div>
<div class="text-sm text-gray-500 mt-2 mb-4">
Configure an LDAP directory for SSO authentication. This will allow users to sign in using their LDAP credentials.
<% if @provider_type == "oidc" %>
Configure an OpenID Connect provider for SSO authentication. This will allow users to sign in using your identity provider.
<% else %>
Configure an LDAP directory for SSO authentication. This will allow users to sign in using their LDAP credentials.
<% end %>
</div>
<%= form_with model: @sso_provider, url: sso_provider_path do |form| %>
<div class="tabs tabs-boxed mb-4 max-w-sm">
<%= link_to "LDAP", new_sso_provider_path(provider_type: "ldap"), class: "tab #{@provider_type == 'ldap' ? 'tab-active' : ''}" %>
<%= link_to "OIDC", new_sso_provider_path(provider_type: "oidc"), class: "tab #{@provider_type == 'oidc' ? 'tab-active' : ''}" %>
</div>
<%= form_with model: @sso_provider, url: sso_provider_path(provider_type: @provider_type) do |form| %>
<%= render "shared/error_messages", resource: form.object %>
<div class="form-control mt-4 w-full max-w-sm">
<label class="label">
<span class="label-text">Provider Name</span>
</label>
<%= form.text_field :name, class: "input input-bordered", required: true, placeholder: "e.g., Company SSO" %>
<label class="label">
<span class="label-text-alt">A friendly name for this SSO provider</span>
</label>
</div>
<div class="space-y-8">
<%= render(FormFieldComponent.new(
label: "Provider Name",
description: "A friendly name for this SSO provider"
)) do %>
<%= form.text_field :name, class: "input input-bordered w-full", required: true, placeholder: "e.g., Company SSO" %>
<label class="label">
<span class="label-text-alt">* Required</span>
</label>
<% end %>
<div class="form-control mt-1 w-full max-w-sm">
<label class="label cursor-pointer justify-start gap-2">
<%= render(FormFieldComponent.new(
label: "Enabled",
description: "Enable or disable this SSO provider"
)) do %>
<%= form.check_box :enabled, class: "checkbox checkbox-primary" %>
<span class="label-text">Enable this provider</span>
</label>
<% end %>
<%= render(FormFieldComponent.new(
label: "Team Provisioning Mode",
description: "How teams are provisioned for SSO users"
)) do %>
<%= form.select :team_provisioning_mode, SSOProvider.team_provisioning_modes.keys.map { |k| [k.titleize, k] }, {}, class: "select select-bordered w-full" %>
<% end %>
</div>
<div class="form-control mt-4 w-full max-w-sm">
<label class="label">
<span class="label-text">Team Provisioning Mode</span>
</label>
<%= form.select :team_provisioning_mode, SSOProvider.team_provisioning_modes.keys.map { |k| [k.titleize, k] }, {}, class: "select select-bordered" %>
<label class="label">
<span class="label-text-alt">How teams are provisioned for SSO users</span>
</label>
</div>
<div class="divider">LDAP Configuration</div>
<%= render "accounts/sso_providers/ldap/form_fields", ldap_configuration: @ldap_configuration %>
<% if @provider_type == "oidc" %>
<div class="divider">OIDC Configuration</div>
<%= render "accounts/sso_providers/oidc/form_fields", oidc_configuration: @oidc_configuration %>
<% else %>
<div class="divider">LDAP Configuration</div>
<%= render "accounts/sso_providers/ldap/form_fields", ldap_configuration: @ldap_configuration %>
<% end %>
<div class="form-footer mt-6">
<%= form.submit "Create Provider", class: "btn btn-primary" %>

View File

@@ -0,0 +1,121 @@
<%= render "accounts/sso_providers/oidc/redirect_uri_info" %>
<%= fields_for :oidc_configuration, oidc_configuration do |oidc_form| %>
<div class="space-y-8">
<%= render(FormFieldComponent.new(
label: "Issuer URL",
description: "The OIDC provider's issuer URL (used for discovery)"
)) do %>
<%= oidc_form.text_field :issuer, class: "input input-bordered w-full", required: true, placeholder: "https://auth.example.com" %>
<label class="label">
<span class="label-text-alt">* Required</span>
</label>
<% end %>
<%= render(FormFieldComponent.new(
label: "Client ID",
description: "OAuth 2.0 client identifier"
)) do %>
<%= oidc_form.text_field :client_id, class: "input input-bordered w-full", required: true, placeholder: "your-client-id" %>
<label class="label">
<span class="label-text-alt">* Required</span>
</label>
<% end %>
<%= render(FormFieldComponent.new(
label: "Client Secret",
description: "OAuth 2.0 client secret"
)) do %>
<%= oidc_form.password_field :client_secret, class: "input input-bordered w-full", required: true %>
<label class="label">
<span class="label-text-alt">* Required</span>
</label>
<% end %>
<%= render(FormFieldComponent.new(
label: "Scopes",
description: "Space-separated list of scopes to request"
)) do %>
<%= oidc_form.text_field :scopes, class: "input input-bordered w-full", placeholder: "openid email profile" %>
<label class="label">
<span class="label-text-alt">Default: openid email profile</span>
</label>
<% end %>
</div>
<details class="collapse collapse-arrow bg-base-200 mt-8">
<summary class="collapse-title font-medium">
Advanced Settings (Optional)
</summary>
<div class="collapse-content">
<div class="alert mt-4 mb-6">
<iconify-icon icon="lucide:info" class="mr-1"></iconify-icon>
<span class="text-sm">Leave these blank to use OIDC discovery. Only fill in if your provider doesn't support discovery.</span>
</div>
<div class="space-y-8">
<%= render(FormFieldComponent.new(
label: "Authorization Endpoint",
description: "URL for authorization requests"
)) do %>
<%= oidc_form.text_field :authorization_endpoint, class: "input input-bordered w-full", placeholder: "https://auth.example.com/authorize" %>
<% end %>
<%= render(FormFieldComponent.new(
label: "Token Endpoint",
description: "URL for token requests"
)) do %>
<%= oidc_form.text_field :token_endpoint, class: "input input-bordered w-full", placeholder: "https://auth.example.com/oauth/token" %>
<% end %>
<%= render(FormFieldComponent.new(
label: "UserInfo Endpoint",
description: "URL for fetching user information"
)) do %>
<%= oidc_form.text_field :userinfo_endpoint, class: "input input-bordered w-full", placeholder: "https://auth.example.com/userinfo" %>
<% end %>
<%= render(FormFieldComponent.new(
label: "JWKS URI",
description: "URL for JSON Web Key Set"
)) do %>
<%= oidc_form.text_field :jwks_uri, class: "input input-bordered w-full", placeholder: "https://auth.example.com/.well-known/jwks.json" %>
<% end %>
</div>
<div class="divider">Claim Mappings</div>
<div class="space-y-8">
<%= render(FormFieldComponent.new(
label: "UID Claim",
description: "Claim used as user identifier"
)) do %>
<%= oidc_form.text_field :uid_claim, class: "input input-bordered w-full", placeholder: "sub" %>
<label class="label">
<span class="label-text-alt">Default: sub</span>
</label>
<% end %>
<%= render(FormFieldComponent.new(
label: "Email Claim",
description: "Claim used for email address"
)) do %>
<%= oidc_form.text_field :email_claim, class: "input input-bordered w-full", placeholder: "email" %>
<label class="label">
<span class="label-text-alt">Default: email</span>
</label>
<% end %>
<%= render(FormFieldComponent.new(
label: "Name Claim",
description: "Claim used for display name"
)) do %>
<%= oidc_form.text_field :name_claim, class: "input input-bordered w-full", placeholder: "name" %>
<label class="label">
<span class="label-text-alt">Default: name</span>
</label>
<% end %>
</div>
</div>
</details>
<% end %>

View File

@@ -0,0 +1,8 @@
<div class="alert 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>
</div>
</div>

View File

@@ -10,7 +10,11 @@
<div>
<h3 class="text-lg font-semibold"><%= @sso_provider.name %></h3>
<div class="flex gap-2 mt-2">
<span class="badge badge-outline">LDAP</span>
<% if @sso_provider.ldap? %>
<span class="badge badge-outline">LDAP</span>
<% elsif @sso_provider.oidc? %>
<span class="badge badge-outline">OIDC</span>
<% end %>
<% if @sso_provider.enabled %>
<span class="badge badge-success">Enabled</span>
<% else %>
@@ -26,24 +30,46 @@
<% if @configuration %>
<div class="divider"></div>
<div class="grid grid-cols-2 gap-4">
<div>
<div class="text-sm font-medium text-base-content/70 mb-1">Host</div>
<div class="font-mono"><%= @configuration.host %>:<%= @configuration.port %></div>
<% if @sso_provider.ldap? %>
<div class="grid grid-cols-2 gap-4">
<div>
<div class="text-sm font-medium text-base-content/70 mb-1">Host</div>
<div class="font-mono"><%= @configuration.host %>:<%= @configuration.port %></div>
</div>
<div>
<div class="text-sm font-medium text-base-content/70 mb-1">Base DN</div>
<div class="font-mono"><%= @configuration.base_dn %></div>
</div>
<div>
<div class="text-sm font-medium text-base-content/70 mb-1">UID Attribute</div>
<div class="font-mono"><%= @configuration.uid_attribute %></div>
</div>
<div>
<div class="text-sm font-medium text-base-content/70 mb-1">Encryption</div>
<div class="font-mono"><%= @configuration.encryption.titleize %></div>
</div>
</div>
<div>
<div class="text-sm font-medium text-base-content/70 mb-1">Base DN</div>
<div class="font-mono"><%= @configuration.base_dn %></div>
<% elsif @sso_provider.oidc? %>
<%= render "accounts/sso_providers/oidc/redirect_uri_info" %>
<div class="grid grid-cols-2 gap-4">
<div>
<div class="text-sm font-medium text-base-content/70 mb-1">Issuer</div>
<div class="font-mono text-sm break-all"><%= @configuration.issuer %></div>
</div>
<div>
<div class="text-sm font-medium text-base-content/70 mb-1">Client ID</div>
<div class="font-mono text-sm"><%= @configuration.client_id %></div>
</div>
<div>
<div class="text-sm font-medium text-base-content/70 mb-1">Scopes</div>
<div class="font-mono text-sm"><%= @configuration.scopes %></div>
</div>
<div>
<div class="text-sm font-medium text-base-content/70 mb-1">UID Claim</div>
<div class="font-mono text-sm"><%= @configuration.uid_claim %></div>
</div>
</div>
<div>
<div class="text-sm font-medium text-base-content/70 mb-1">UID Attribute</div>
<div class="font-mono"><%= @configuration.uid_attribute %></div>
</div>
<div>
<div class="text-sm font-medium text-base-content/70 mb-1">Encryption</div>
<div class="font-mono"><%= @configuration.encryption.titleize %></div>
</div>
</div>
<% end %>
<% end %>
</div>
</div>
@@ -54,7 +80,8 @@
</div>
<div class="flex gap-2">
<%= link_to "+ Add LDAP Provider", new_sso_provider_path, class: "btn btn-primary btn-sm" %>
<%= link_to "+ Add LDAP Provider", new_sso_provider_path(provider_type: "ldap"), class: "btn btn-primary btn-sm" %>
<%= link_to "+ Add OIDC Provider", new_sso_provider_path(provider_type: "oidc"), class: "btn btn-primary btn-sm" %>
</div>
<% end %>
<% end %>

View File

@@ -0,0 +1,40 @@
<div class="flex justify-center items-center min-h-screen bg-base-200">
<div class="card w-96 bg-base-100 shadow-xl">
<div class="card-body">
<h1 class="text-2xl font-bold text-center">Sign in</h1>
<div class="text-center text-sm">
Sign in to access your account
</div>
<% if @account.stack_manager&.portainer? %>
<div class="flex justify-center mt-2">
<%= render "devise/sessions/portainer_badge", stack_manager: @account.stack_manager, logged_in: false %>
</div>
<% end %>
<% if flash[:alert] %>
<div class="alert alert-error mb-4 mt-4">
<iconify-icon icon="lucide:alert-triangle" class="mr-2 text-white"></iconify-icon>
<span><%= flash[:alert] %></span>
</div>
<% end %>
<div class="mt-6">
<%= link_to oidc_auth_path(@account.slug), class: "btn btn-primary w-full", data: { turbo: false } do %>
<iconify-icon icon="lucide:log-in" height="18"></iconify-icon>
Sign in with <%= @sso_provider.name %>
<% end %>
</div>
<div class="divider">New to <%= @account.name %>?</div>
<p class="text-center text-sm">
Contact your account administrator to get access
</p>
<div class="mt-4 text-center">
<%= link_to "Back to main login", new_user_session_path, class: "link link-primary text-sm" %>
</div>
</div>
</div>
</div>

View File

@@ -14,4 +14,5 @@
ActiveSupport::Inflector.inflections(:en) do |inflect|
inflect.acronym "SSO"
inflect.acronym "LDAP"
inflect.acronym "OIDC"
end

View File

@@ -5,6 +5,10 @@ Rails.application.routes.draw do
post '/accounts/:slug/sign_in', to: 'users/sessions#account_create'
end
# OIDC authentication routes
get '/accounts/:slug/auth/oidc', to: 'accounts/oidc#authorize', as: :oidc_auth
get '/accounts/:slug/auth/oidc/callback', to: 'accounts/oidc#callback', as: :oidc_callback
authenticate :user, ->(user) { user.admin? } do
mount Avo::Engine, at: Avo.configuration.root_path
Avo::Engine.routes.draw do

View File

@@ -0,0 +1,19 @@
class CreateOIDCConfigurations < ActiveRecord::Migration[7.2]
def change
create_table :oidc_configurations do |t|
t.string :issuer, null: false
t.string :client_id, null: false
t.string :client_secret, null: false
t.string :authorization_endpoint
t.string :token_endpoint
t.string :userinfo_endpoint
t.string :jwks_uri
t.string :scopes, default: "openid email profile"
t.string :uid_claim, default: "sub", null: false
t.string :email_claim, default: "email"
t.string :name_claim, default: "name"
t.timestamps
end
end
end

18
db/schema.rb generated
View File

@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2025_11_26_014509) do
ActiveRecord::Schema[7.2].define(version: 2025_12_11_151950) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@@ -393,6 +393,22 @@ ActiveRecord::Schema[7.2].define(version: 2025_11_26_014509) do
t.index ["recipient_type", "recipient_id"], name: "index_noticed_notifications_on_recipient"
end
create_table "oidc_configurations", force: :cascade do |t|
t.string "issuer", null: false
t.string "client_id", null: false
t.string "client_secret", null: false
t.string "authorization_endpoint"
t.string "token_endpoint"
t.string "userinfo_endpoint"
t.string "jwks_uri"
t.string "scopes", default: "openid email profile"
t.string "uid_claim", default: "sub", null: false
t.string "email_claim", default: "email"
t.string "name_claim", default: "name"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
create_table "project_add_ons", force: :cascade do |t|
t.bigint "project_id", null: false
t.bigint "add_on_id", null: false

View File

@@ -8,6 +8,7 @@ x-shared-env: &shared-env
services:
postgres:
restart: unless-stopped
image: postgres:16-bookworm@sha256:878977a5fe8d75ba7eab7610e4cf7e0c8626a683d89b3f9da965b8ceba952a09
environment:
- POSTGRES_USER=postgres
@@ -17,13 +18,20 @@ services:
- "5432:5432"
volumes:
- "postgres:/var/lib/postgresql/data"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 30s
retries: 5
start_period: 20s
web:
restart: unless-stopped
image: ghcr.io/caninehq/canine:latest
# Overrides default command so things don't shut down after the process ends.
# command: sleep infinity
depends_on:
- postgres
postgres:
condition: service_healthy
stdin_open: true
tty: true
ports:
@@ -37,10 +45,12 @@ services:
- ${DOCKER_SOCKET:-/var/run/docker.sock}:/var/run/docker.sock
worker:
restart: unless-stopped
image: ghcr.io/caninehq/canine:latest
command: bundle exec good_job start
depends_on:
- postgres
postgres:
condition: service_healthy
environment:
<<: *shared-env
LOCAL_MODE: "true"

View File

@@ -8,6 +8,7 @@ x-shared-env: &shared-env
services:
postgres:
restart: unless-stopped
image: postgres:16-bookworm@sha256:878977a5fe8d75ba7eab7610e4cf7e0c8626a683d89b3f9da965b8ceba952a09
environment:
- POSTGRES_USER=postgres
@@ -17,13 +18,20 @@ services:
- "5432:5432"
volumes:
- "postgres:/var/lib/postgresql/data"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 30s
retries: 5
start_period: 20s
web:
restart: unless-stopped
image: ghcr.io/caninehq/canine:latest
# Overrides default command so things don't shut down after the process ends.
# command: sleep infinity
depends_on:
- postgres
postgres:
condition: service_healthy
stdin_open: true
tty: true
ports:
@@ -37,10 +45,12 @@ services:
- ${DOCKER_SOCKET:-/var/run/docker.sock}:/var/run/docker.sock
worker:
restart: unless-stopped
image: ghcr.io/caninehq/canine:latest
command: bundle exec good_job start
depends_on:
- postgres
postgres:
condition: service_healthy
environment:
<<: *shared-env
LOCAL_MODE: "true"

View File

@@ -0,0 +1,34 @@
# == 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
#
FactoryBot.define do
factory :oidc_configuration do
issuer { "https://auth.example.com" }
client_id { "canine-client" }
client_secret { "super-secret-key" }
authorization_endpoint { nil }
token_endpoint { nil }
userinfo_endpoint { nil }
jwks_uri { nil }
scopes { "openid email profile" }
uid_claim { "sub" }
email_claim { "email" }
name_claim { "name" }
end
end

View File

@@ -0,0 +1,64 @@
# == 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
#
require 'rails_helper'
RSpec.describe OIDCConfiguration, type: :model do
describe "validations" do
it "is valid with valid attributes" do
config = build(:oidc_configuration)
expect(config).to be_valid
end
it "requires issuer, client_id, and client_secret" do
config = build(:oidc_configuration, issuer: nil, client_id: nil, client_secret: nil)
expect(config).not_to be_valid
expect(config.errors[:issuer]).to be_present
expect(config.errors[:client_id]).to be_present
expect(config.errors[:client_secret]).to be_present
end
end
describe "#discovery_url" do
it "returns the well-known configuration URL" do
config = build(:oidc_configuration, issuer: "https://auth.example.com")
expect(config.discovery_url).to eq("https://auth.example.com/.well-known/openid-configuration")
end
it "strips trailing slash from issuer" do
config = build(:oidc_configuration, issuer: "https://auth.example.com/")
expect(config.discovery_url).to eq("https://auth.example.com/.well-known/openid-configuration")
end
end
describe "#uses_discovery?" do
it "returns true when endpoints are blank" do
config = build(:oidc_configuration, authorization_endpoint: nil, token_endpoint: nil)
expect(config.uses_discovery?).to be true
end
it "returns false when endpoints are configured" do
config = build(:oidc_configuration,
authorization_endpoint: "https://auth.example.com/authorize",
token_endpoint: "https://auth.example.com/token"
)
expect(config.uses_discovery?).to be false
end
end
end

View File

@@ -1,3 +1,30 @@
# == Schema Information
#
# Table name: providers
#
# id :bigint not null, primary key
# access_token :string
# access_token_secret :string
# auth :text
# expires_at :datetime
# last_used_at :datetime
# provider :string
# refresh_token :string
# registry_url :string
# uid :string
# created_at :datetime not null
# updated_at :datetime not null
# external_id :string
# user_id :bigint not null
#
# Indexes
#
# index_providers_on_user_id (user_id)
#
# Foreign Keys
#
# fk_rails_... (user_id => users.id)
#
require 'rails_helper'
RSpec.describe Provider, type: :model do