Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 14 additions & 21 deletions .rubocop_gradual.lock
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
{
"lib/omniauth-ldap/adaptor.rb:715274645": [
[64, 7, 413, "Style/ClassMethodsDefinitions: Use `class << self` to define a class method.", 105664470],
[114, 17, 3, "Style/AndOr: Use `&&` instead of `and`.", 193409806],
[114, 30, 3, "Style/AndOr: Use `&&` instead of `and`.", 193409806],
[114, 37, 1, "Lint/AssignmentInCondition: Wrap assignment in parentheses if intentional", 177560]
"lib/omniauth-ldap/adaptor.rb:3212021924": [
[65, 7, 413, "Style/ClassMethodsDefinitions: Use `class << self` to define a class method.", 105664470]
],
"spec/integration/middleware_spec.rb:4142891586": [
[3, 16, 39, "RSpec/DescribeClass: The first argument to describe should be the class or module being tested.", 638096201],
Expand All @@ -15,29 +12,25 @@
[4, 3, 12, "RSpec/BeforeAfterAll: Beware of using `before(:all)` as it may cause state to leak between tests. If you are using `rspec-rails`, and `use_transactional_fixtures` is enabled, then records created in `before(:all)` are not automatically rolled back.", 86334566],
[70, 16, 5, "RSpec/ExpectActual: Provide the actual value you are testing to `expect(...)`.", 237881235]
],
"spec/omniauth-ldap/adaptor_spec.rb:2715031579": [
"spec/omniauth-ldap/adaptor_spec.rb:321639549": [
[3, 1, 38, "RSpec/SpecFilePathFormat: Spec path should end with `omni_auth/ldap/adaptor*_spec.rb`.", 1973618936],
[206, 7, 26, "RSpec/StubbedMock: Prefer `allow` over `expect` when configuring a response.", 1924417310],
[207, 7, 26, "RSpec/StubbedMock: Prefer `allow` over `expect` when configuring a response.", 1924417310],
[208, 7, 26, "RSpec/StubbedMock: Prefer `allow` over `expect` when configuring a response.", 1924417310],
[214, 7, 26, "RSpec/StubbedMock: Prefer `allow` over `expect` when configuring a response.", 1924417310],
[215, 7, 26, "RSpec/StubbedMock: Prefer `allow` over `expect` when configuring a response.", 1924417310],
[216, 7, 26, "RSpec/StubbedMock: Prefer `allow` over `expect` when configuring a response.", 1924417310]
[225, 9, 26, "RSpec/StubbedMock: Prefer `allow` over `expect` when configuring a response.", 1924417310],
[226, 9, 26, "RSpec/StubbedMock: Prefer `allow` over `expect` when configuring a response.", 1924417310]
],
"spec/omniauth/adaptor_spec.rb:1168013709": [
[3, 1, 38, "RSpec/SpecFilePathFormat: Spec path should end with `omni_auth/ldap/adaptor*_spec.rb`.", 1973618936],
[46, 7, 38, "RSpec/AnyInstance: Avoid stubbing using `allow_any_instance_of`.", 3627954156],
[47, 7, 38, "RSpec/AnyInstance: Avoid stubbing using `allow_any_instance_of`.", 3627954156],
[84, 7, 48, "RSpec/AnyInstance: Avoid stubbing using `allow_any_instance_of`.", 2759780562]
],
"spec/omniauth/strategies/ldap_spec.rb:2044523926": [
[120, 13, 9, "RSpec/ContextWording: Context description should match /^when\\b/, /^with\\b/, or /^without\\b/.", 1130140517],
[175, 17, 28, "RSpec/ContextWording: Context description should match /^when\\b/, /^with\\b/, or /^without\\b/.", 3444838747],
[184, 17, 23, "RSpec/ContextWording: Context description should match /^when\\b/, /^with\\b/, or /^without\\b/.", 1584148894],
[195, 17, 32, "RSpec/ContextWording: Context description should match /^when\\b/, /^with\\b/, or /^without\\b/.", 1515076977],
[204, 19, 19, "RSpec/ContextWording: Context description should match /^when\\b/, /^with\\b/, or /^without\\b/.", 2526348694],
[230, 17, 56, "RSpec/ContextWording: Context description should match /^when\\b/, /^with\\b/, or /^without\\b/.", 2413495789],
[245, 13, 9, "RSpec/ContextWording: Context description should match /^when\\b/, /^with\\b/, or /^without\\b/.", 3182939526],
[278, 15, 19, "RSpec/ContextWording: Context description should match /^when\\b/, /^with\\b/, or /^without\\b/.", 2526348694]
"spec/omniauth/strategies/ldap_spec.rb:1834231975": [
[126, 13, 9, "RSpec/ContextWording: Context description should match /^when\\b/, /^with\\b/, or /^without\\b/.", 1130140517],
[181, 17, 28, "RSpec/ContextWording: Context description should match /^when\\b/, /^with\\b/, or /^without\\b/.", 3444838747],
[190, 17, 23, "RSpec/ContextWording: Context description should match /^when\\b/, /^with\\b/, or /^without\\b/.", 1584148894],
[201, 17, 32, "RSpec/ContextWording: Context description should match /^when\\b/, /^with\\b/, or /^without\\b/.", 1515076977],
[243, 19, 19, "RSpec/ContextWording: Context description should match /^when\\b/, /^with\\b/, or /^without\\b/.", 2526348694],
[269, 17, 56, "RSpec/ContextWording: Context description should match /^when\\b/, /^with\\b/, or /^without\\b/.", 2413495789],
[284, 13, 9, "RSpec/ContextWording: Context description should match /^when\\b/, /^with\\b/, or /^without\\b/.", 3182939526],
[338, 15, 19, "RSpec/ContextWording: Context description should match /^when\\b/, /^with\\b/, or /^without\\b/.", 2526348694]
]
}
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ Please file a bug if you notice a violation of semantic versioning.
- Support for SCRIPT_NAME for proper URL generation
- behind certain proxies/load balancers, or
- under a subdirectory
- Password Policy for LDAP Directories
- password_policy: true|false (default: false)
- on authentication failure, if the server returns password policy controls, the info will be included in the failure message
- https://datatracker.ietf.org/doc/html/draft-behera-ldap-password-policy-11

### Changed

Expand Down
24 changes: 23 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,25 @@ The following options are available for configuring the OmniAuth LDAP strategy:
- `:sasl_mechanisms` - Array of SASL mechanisms to use (e.g., ["DIGEST-MD5", "GSS-SPNEGO"]).
- `:allow_anonymous` - Whether to allow anonymous binding (default: false).
- `:logger` - A logger instance for debugging (optional, for internal use).
- `:password_policy` - When true, the strategy will request the LDAP Password Policy response control (OID `1.3.6.1.4.1.42.2.27.8.5.1`) during the user bind. If the server supports it, the adaptor exposes:
- `adaptor.last_operation_result` — the last Net::LDAP operation result object.
- `adaptor.last_password_policy_response` — the matching password policy response control (implementation-specific object). This can indicate conditions such as password expired, account locked, reset required, or grace logins remaining (per the draft RFC).

Example enabling password policy:

```ruby
use OmniAuth::Builder do
provider :ldap,
host: "ldap.example.com",
base: "dc=example,dc=com",
uid: "uid",
bind_dn: "cn=search,dc=example,dc=com",
password: ENV["LDAP_SEARCH_PASSWORD"],
password_policy: true
end
```

Note: This is best-effort and compatible with a range of net-ldap versions. If your server supports the control, you can inspect the response via the `adaptor` instance during/after authentication (for example in a failure handler) to tailor error messages.

### Auth Hash UID vs LDAP :uid (search attribute)

Expand Down Expand Up @@ -453,7 +472,10 @@ Rails.application.config.middleware.use(OmniAuth::Builder) do
title: "Acme LDAP",
host: "ldap.acme.internal",
base: "dc=acme,dc=corp",
uid: "uid"
uid: "uid",
bind_dn: "cn=search,dc=acme,dc=corp",
password: ENV["LDAP_SEARCH_PASSWORD"],
name_proc: proc { |n| n.split("@").first }
end
```

Expand Down
78 changes: 65 additions & 13 deletions lib/omniauth-ldap/adaptor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ class ConnectionError < StandardError; end
:allow_anonymous,
:filter,
:tls_options,
:password_policy,

# Deprecated
:method,
Expand Down Expand Up @@ -59,7 +60,7 @@ class ConnectionError < StandardError; end
}

attr_accessor :bind_dn, :password
attr_reader :connection, :uid, :base, :auth, :filter
attr_reader :connection, :uid, :base, :auth, :filter, :password_policy, :last_operation_result, :last_password_policy_response

def self.validate(configuration = {})
message = []
Expand Down Expand Up @@ -109,20 +110,45 @@ def initialize(configuration = {})
# :password => psw
def bind_as(args = {})
result = false
@last_operation_result = nil
@last_password_policy_response = nil
@connection.open do |me|
rs = me.search(args)
if rs and rs.first and dn = rs.first.dn
password = args[:password]
method = args[:method] || @method
password = password.call if password.respond_to?(:call)
if method == "sasl"
result = rs.first if me.bind(sasl_auths({username: dn, password: password}).first)
elsif me.bind(
method: :simple,
username: dn,
password: password,
)
result = rs.first
if rs && rs.first
dn = rs.first.dn
if dn
password = args[:password]
password = password.call if password.respond_to?(:call)

bind_args = if @bind_method == :sasl
sasl_auths({username: dn, password: password}).first
else
{
method: :simple,
username: dn,
password: password,
}
end

# Optionally request LDAP Password Policy control (RFC Draft - de facto standard)
if @password_policy
# Always request by OID using a simple hash; avoids depending on gem-specific control classes
control = {oid: "1.3.6.1.4.1.42.2.27.8.5.1", criticality: true, value: nil}
if bind_args.is_a?(Hash)
bind_args = bind_args.merge({controls: [control]})
else
# Some Net::LDAP versions allow passing a block for SASL only; ensure we still can add controls if hash
# When not a Hash, we can't merge; rely on server default behavior.
end
end

begin
success = bind_args ? me.bind(bind_args) : me.bind
ensure
capture_password_policy(me)
end

result = rs.first if success
end
end
end
Expand Down Expand Up @@ -250,6 +276,32 @@ def symbolize_hash_keys(hash)
result[key.to_sym] = value
end
end

# Capture the operation result and extract any Password Policy response control if present.
def capture_password_policy(conn)
return unless @password_policy
return unless conn.respond_to?(:get_operation_result)

begin
@last_operation_result = conn.get_operation_result
controls = if @last_operation_result && @last_operation_result.respond_to?(:controls)
@last_operation_result.controls || []
else
[]
end
if controls.any?
# Find Password Policy response control by OID
ppolicy_oid = "1.3.6.1.4.1.42.2.27.8.5.1"
ctrl = controls.find do |c|
(c.respond_to?(:oid) && c.oid == ppolicy_oid) || (c.is_a?(Hash) && c[:oid] == ppolicy_oid)
end
@last_password_policy_response = ctrl if ctrl
end
rescue StandardError
# Swallow errors to keep authentication flow unaffected when server or gem doesn't support controls
@last_password_policy_response = nil
end
end
end
end
end
48 changes: 47 additions & 1 deletion lib/omniauth/strategies/ldap.rb
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,14 @@ def callback_phase
@ldap_user_info = @adaptor.bind_as(filter: filter(@adaptor), size: 1, password: request.params["password"])

unless @ldap_user_info
# Attach password policy info to env if available (best-effort)
attach_password_policy_env(@adaptor)
return fail!(:invalid_credentials, InvalidCredentialsError.new("Invalid credentials for #{request.params["username"]}"))
end

# Optionally attach policy info even on success (e.g., timeBeforeExpiration)
attach_password_policy_env(@adaptor)

@user_info = self.class.map_user(CONFIG, @ldap_user_info)
super
rescue => e
Expand Down Expand Up @@ -197,10 +202,51 @@ def directory_lookup(adaptor, username)
search_filter = filter(adaptor, username)
adaptor.connection.open do |conn|
rs = conn.search(filter: search_filter, size: 1)
entry = rs.first if rs && rs.first
entry = rs && rs.first
end
entry
end

# If the adaptor captured a Password Policy response control, expose a minimal, stable hash
# in the Rack env for applications to inspect.
def attach_password_policy_env(adaptor)
return unless adaptor.respond_to?(:password_policy) && adaptor.password_policy
ctrl = adaptor.respond_to?(:last_password_policy_response) ? adaptor.last_password_policy_response : nil
op = adaptor.respond_to?(:last_operation_result) ? adaptor.last_operation_result : nil
return unless ctrl || op

request.env["omniauth.ldap.password_policy"] = extract_password_policy(ctrl, op)
end

# Best-effort extraction across net-ldap versions; if fields are not available, returns a raw payload.
def extract_password_policy(control, operation)
data = {raw: control}
if control
# Prefer named readers if present
if control.respond_to?(:error)
data[:error] = control.public_send(:error)
elsif control.respond_to?(:ppolicy_error)
data[:error] = control.public_send(:ppolicy_error)
end
if control.respond_to?(:time_before_expiration)
data[:time_before_expiration] = control.public_send(:time_before_expiration)
end
if control.respond_to?(:grace_authns_remaining)
data[:grace_authns_remaining] = control.public_send(:grace_authns_remaining)
elsif control.respond_to?(:grace_logins_remaining)
data[:grace_authns_remaining] = control.public_send(:grace_logins_remaining)
end
if control.respond_to?(:oid)
data[:oid] = control.public_send(:oid)
end
end
if operation
code = operation.respond_to?(:code) ? operation.code : nil
message = operation.respond_to?(:message) ? operation.message : nil
data[:operation] = {code: code, message: message}
end
data
end
end
end
end
Expand Down
5 changes: 5 additions & 0 deletions sig/omniauth-ldap.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,8 @@
# - sig/omniauth/ldap/adaptor.rbs
# - sig/omniauth/strategies/ldap.rbs
# This file is intentionally minimal to avoid duplicating declarations.

module OmniAuth
module LDAP
end
end
30 changes: 24 additions & 6 deletions sig/omniauth/ldap/adaptor.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ module OmniAuth

VALID_ADAPTER_CONFIGURATION_KEYS: Array[Symbol]
MUST_HAVE_KEYS: Array[untyped]
METHOD: Hash[Symbol, Symbol?]
ENCRYPTION_METHOD: Hash[Symbol, Symbol?]

attr_accessor bind_dn: String?
attr_accessor password: String?
Expand All @@ -28,6 +28,10 @@ module OmniAuth
attr_reader auth: Hash[Symbol, untyped]
# filter is an LDAP filter string when configured
attr_reader filter: String?
# optional: request password policy control and capture response
attr_reader password_policy: bool?
attr_reader last_operation_result: untyped
attr_reader last_password_policy_response: untyped

# Validate that required keys exist in the configuration
def self.validate: (?Hash[Symbol, untyped]) -> void
Expand All @@ -38,17 +42,31 @@ module OmniAuth

private

# Returns a Net::LDAP encryption symbol (e.g. :simple_tls, :start_tls) or nil
def ensure_method: (untyped) -> Symbol?
# Returns encryption settings hash or nil
def encryption_options: () -> Hash[Symbol, untyped]?
# Translate configured method/encryption into Net::LDAP symbol
def translate_method: () -> Symbol?
# Returns a Net::LDAP encryption default options hash
def default_options: () -> Hash[Symbol, untyped]
# Sanitize provided TLS options
def sanitize_hash_values: (Hash[untyped, untyped]) -> Hash[untyped, untyped]
# Symbolize option keys
def symbolize_hash_keys: (Hash[untyped, untyped]) -> Hash[Symbol, untyped]
# Capture password policy control from last operation
def capture_password_policy: (Net::LDAP) -> void

# Returns an array of SASL auth hashes
def sasl_auths: (?Hash[Symbol, untyped]) -> Array[Hash[Symbol, untyped]]

# Returns initial credential (string) and a proc that accepts a challenge and returns the response
# Use Array[untyped] here to avoid tuple syntax issues in some linters; the runtime value
# is commonly a two-element array [initial_credential, proc].
# Returns initial credential and a proc that accepts a challenge and returns the response
def sasl_bind_setup_digest_md5: (?Hash[Symbol, untyped]) -> Array[untyped]
def sasl_bind_setup_gss_spnego: (?Hash[Symbol, untyped]) -> Array[untyped]

@try_sasl: bool?
@allow_anonymous: bool?
@tls_options: Hash[untyped, untyped]?
@sasl_mechanisms: Array[String]?
@disable_verify_certificates: bool?
end
end
end
6 changes: 6 additions & 0 deletions sig/omniauth/strategies/ldap.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ module OmniAuth

# Perform a directory lookup for a given username; returns an Entry or nil
def directory_lookup: (OmniAuth::LDAP::Adaptor, String) -> untyped

def uid: () { () -> String } -> void

def info: () { () -> Hash[untyped, untyped] } -> void

def extra: () { () -> Hash[Symbol, untyped] } -> void
end
end
end
18 changes: 17 additions & 1 deletion sig/rbs/net-ldap.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ module Net
def open: () { (self) -> untyped } -> untyped
def search: (?Hash[Symbol, untyped]) -> Array[Net::LDAP::Entry]
def bind: (?Hash[Symbol, untyped]) -> bool
def get_operation_result: () -> Net::LDAP::PDU
end

class LDAP::Entry
Expand All @@ -15,5 +16,20 @@ module Net
def self.construct: (String) -> Net::LDAP::Filter
def self.eq: (String, String) -> Net::LDAP::Filter
end
end

class LDAP::Control
def initialize: (String, bool, untyped) -> void
def oid: () -> String
end

module LDAP::Controls
class PasswordPolicy
def initialize: () -> void
def oid: () -> String
end
end

class LDAP::PDU
def controls: () -> Array[untyped]
end
end
Loading
Loading