Skip to content

Commit 12b2ff6

Browse files
committed
1 parent 1115b69 commit 12b2ff6

File tree

9 files changed

+229
-23
lines changed

9 files changed

+229
-23
lines changed

README.md

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,25 @@ The following options are available for configuring the OmniAuth LDAP strategy:
191191
- `:sasl_mechanisms` - Array of SASL mechanisms to use (e.g., ["DIGEST-MD5", "GSS-SPNEGO"]).
192192
- `:allow_anonymous` - Whether to allow anonymous binding (default: false).
193193
- `:logger` - A logger instance for debugging (optional, for internal use).
194+
- `: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:
195+
- `adaptor.last_operation_result` — the last Net::LDAP operation result object.
196+
- `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).
197+
198+
Example enabling password policy:
199+
200+
```ruby
201+
use OmniAuth::Builder do
202+
provider :ldap,
203+
host: "ldap.example.com",
204+
base: "dc=example,dc=com",
205+
uid: "uid",
206+
bind_dn: "cn=search,dc=example,dc=com",
207+
password: ENV["LDAP_SEARCH_PASSWORD"],
208+
password_policy: true
209+
end
210+
```
211+
212+
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.
194213

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

@@ -453,7 +472,10 @@ Rails.application.config.middleware.use(OmniAuth::Builder) do
453472
title: "Acme LDAP",
454473
host: "ldap.acme.internal",
455474
base: "dc=acme,dc=corp",
456-
uid: "uid"
475+
uid: "uid",
476+
bind_dn: "cn=search,dc=acme,dc=corp",
477+
password: ENV["LDAP_SEARCH_PASSWORD"],
478+
name_proc: proc { |n| n.split("@").first }
457479
end
458480
```
459481

lib/omniauth-ldap/adaptor.rb

Lines changed: 80 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ class ConnectionError < StandardError; end
3131
:allow_anonymous,
3232
:filter,
3333
:tls_options,
34+
:password_policy,
3435

3536
# Deprecated
3637
:method,
@@ -59,7 +60,7 @@ class ConnectionError < StandardError; end
5960
}
6061

6162
attr_accessor :bind_dn, :password
62-
attr_reader :connection, :uid, :base, :auth, :filter
63+
attr_reader :connection, :uid, :base, :auth, :filter, :password_policy, :last_operation_result, :last_password_policy_response
6364

6465
def self.validate(configuration = {})
6566
message = []
@@ -109,20 +110,60 @@ def initialize(configuration = {})
109110
# :password => psw
110111
def bind_as(args = {})
111112
result = false
113+
@last_operation_result = nil
114+
@last_password_policy_response = nil
112115
@connection.open do |me|
113116
rs = me.search(args)
114-
if rs and rs.first and dn = rs.first.dn
115-
password = args[:password]
116-
method = args[:method] || @method
117-
password = password.call if password.respond_to?(:call)
118-
if method == "sasl"
119-
result = rs.first if me.bind(sasl_auths({username: dn, password: password}).first)
120-
elsif me.bind(
121-
method: :simple,
122-
username: dn,
123-
password: password,
124-
)
125-
result = rs.first
117+
if rs && rs.first
118+
dn = rs.first.dn
119+
if dn
120+
password = args[:password]
121+
method = args[:method] || @method
122+
password = password.call if password.respond_to?(:call)
123+
124+
bind_args = if method == "sasl"
125+
sasl_auths({username: dn, password: password}).first
126+
else
127+
{
128+
method: :simple,
129+
username: dn,
130+
password: password,
131+
}
132+
end
133+
134+
# Optionally request LDAP Password Policy control (RFC Draft - de facto standard)
135+
if @password_policy
136+
# Best-effort: if specific control class is available use it; otherwise request by OID
137+
control = begin
138+
if defined?(Net::LDAP::Control::PasswordPolicy)
139+
Net::LDAP::Control::PasswordPolicy.new
140+
elsif defined?(Net::LDAP::Controls) && defined?(Net::LDAP::Controls::PasswordPolicy)
141+
Net::LDAP::Controls::PasswordPolicy.new
142+
elsif defined?(Net::LDAP::Control) && Net::LDAP::Control.respond_to?(:new)
143+
# Arguments: oid, critical, value
144+
Net::LDAP::Control.new("1.3.6.1.4.1.42.2.27.8.5.1", true, nil)
145+
else
146+
# Fallback to a simple hash - useful for testing with stubs
147+
{oid: "1.3.6.1.4.1.42.2.27.8.5.1", criticality: true, value: nil}
148+
end
149+
rescue StandardError
150+
{oid: "1.3.6.1.4.1.42.2.27.8.5.1", criticality: true, value: nil}
151+
end
152+
if bind_args.is_a?(Hash)
153+
bind_args = bind_args.merge({controls: [control]})
154+
else
155+
# Some Net::LDAP versions allow passing a block for SASL only; ensure we still can add controls if hash
156+
# If not a Hash, we can't merge; rely on server default behavior.
157+
end
158+
end
159+
160+
begin
161+
success = bind_args ? me.bind(bind_args) : me.bind
162+
ensure
163+
capture_password_policy(me)
164+
end
165+
166+
result = rs.first if success
126167
end
127168
end
128169
end
@@ -250,6 +291,32 @@ def symbolize_hash_keys(hash)
250291
result[key.to_sym] = value
251292
end
252293
end
294+
295+
# Capture the operation result and extract any Password Policy response control if present.
296+
def capture_password_policy(conn)
297+
return unless @password_policy
298+
return unless conn.respond_to?(:get_operation_result)
299+
300+
begin
301+
@last_operation_result = conn.get_operation_result
302+
controls = if @last_operation_result && @last_operation_result.respond_to?(:controls)
303+
@last_operation_result.controls || []
304+
else
305+
[]
306+
end
307+
if controls.any?
308+
# Find Password Policy response control by OID
309+
ppolicy_oid = "1.3.6.1.4.1.42.2.27.8.5.1"
310+
ctrl = controls.find do |c|
311+
(c.respond_to?(:oid) && c.oid == ppolicy_oid) || (c.is_a?(Hash) && c[:oid] == ppolicy_oid)
312+
end
313+
@last_password_policy_response = ctrl if ctrl
314+
end
315+
rescue StandardError
316+
# Swallow errors to keep authentication flow unaffected when server or gem doesn't support controls
317+
@last_password_policy_response = nil
318+
end
319+
end
253320
end
254321
end
255322
end

lib/omniauth/strategies/ldap.rb

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,9 +100,14 @@ def callback_phase
100100
@ldap_user_info = @adaptor.bind_as(filter: filter(@adaptor), size: 1, password: request.params["password"])
101101

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

108+
# Optionally attach policy info even on success (e.g., timeBeforeExpiration)
109+
attach_password_policy_env(@adaptor)
110+
106111
@user_info = self.class.map_user(CONFIG, @ldap_user_info)
107112
super
108113
rescue => e
@@ -197,10 +202,51 @@ def directory_lookup(adaptor, username)
197202
search_filter = filter(adaptor, username)
198203
adaptor.connection.open do |conn|
199204
rs = conn.search(filter: search_filter, size: 1)
200-
entry = rs.first if rs && rs.first
205+
entry = rs && rs.first
201206
end
202207
entry
203208
end
209+
210+
# If the adaptor captured a Password Policy response control, expose a minimal, stable hash
211+
# in the Rack env for applications to inspect.
212+
def attach_password_policy_env(adaptor)
213+
return unless adaptor.respond_to?(:password_policy) && adaptor.password_policy
214+
ctrl = adaptor.respond_to?(:last_password_policy_response) ? adaptor.last_password_policy_response : nil
215+
op = adaptor.respond_to?(:last_operation_result) ? adaptor.last_operation_result : nil
216+
return unless ctrl || op
217+
218+
request.env["omniauth.ldap.password_policy"] = extract_password_policy(ctrl, op)
219+
end
220+
221+
# Best-effort extraction across net-ldap versions; if fields are not available, returns a raw payload.
222+
def extract_password_policy(control, operation)
223+
data = {raw: control}
224+
if control
225+
# Prefer named readers if present
226+
if control.respond_to?(:error)
227+
data[:error] = control.public_send(:error)
228+
elsif control.respond_to?(:ppolicy_error)
229+
data[:error] = control.public_send(:ppolicy_error)
230+
end
231+
if control.respond_to?(:time_before_expiration)
232+
data[:time_before_expiration] = control.public_send(:time_before_expiration)
233+
end
234+
if control.respond_to?(:grace_authns_remaining)
235+
data[:grace_authns_remaining] = control.public_send(:grace_authns_remaining)
236+
elsif control.respond_to?(:grace_logins_remaining)
237+
data[:grace_authns_remaining] = control.public_send(:grace_logins_remaining)
238+
end
239+
if control.respond_to?(:oid)
240+
data[:oid] = control.public_send(:oid)
241+
end
242+
end
243+
if operation
244+
code = operation.respond_to?(:code) ? operation.code : nil
245+
message = operation.respond_to?(:message) ? operation.message : nil
246+
data[:operation] = {code: code, message: message}
247+
end
248+
data
249+
end
204250
end
205251
end
206252
end

sig/omniauth-ldap.rbs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,8 @@
33
# - sig/omniauth/ldap/adaptor.rbs
44
# - sig/omniauth/strategies/ldap.rbs
55
# This file is intentionally minimal to avoid duplicating declarations.
6+
7+
module OmniAuth
8+
module LDAP
9+
end
10+
end

sig/omniauth/ldap/adaptor.rbs

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ module OmniAuth
1515

1616
VALID_ADAPTER_CONFIGURATION_KEYS: Array[Symbol]
1717
MUST_HAVE_KEYS: Array[untyped]
18-
METHOD: Hash[Symbol, Symbol?]
18+
ENCRYPTION_METHOD: Hash[Symbol, Symbol?]
1919

2020
attr_accessor bind_dn: String?
2121
attr_accessor password: String?
@@ -28,6 +28,10 @@ module OmniAuth
2828
attr_reader auth: Hash[Symbol, untyped]
2929
# filter is an LDAP filter string when configured
3030
attr_reader filter: String?
31+
# optional: request password policy control and capture response
32+
attr_reader password_policy: bool?
33+
attr_reader last_operation_result: untyped
34+
attr_reader last_password_policy_response: untyped
3135

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

3943
private
4044

41-
# Returns a Net::LDAP encryption symbol (e.g. :simple_tls, :start_tls) or nil
42-
def ensure_method: (untyped) -> Symbol?
45+
# Returns encryption settings hash or nil
46+
def encryption_options: () -> Hash[Symbol, untyped]?
47+
# Translate configured method/encryption into Net::LDAP symbol
48+
def translate_method: () -> Symbol?
49+
# Returns a Net::LDAP encryption default options hash
50+
def default_options: () -> Hash[Symbol, untyped]
51+
# Sanitize provided TLS options
52+
def sanitize_hash_values: (Hash[untyped, untyped]) -> Hash[untyped, untyped]
53+
# Symbolize option keys
54+
def symbolize_hash_keys: (Hash[untyped, untyped]) -> Hash[Symbol, untyped]
55+
# Capture password policy control from last operation
56+
def capture_password_policy: (Net::LDAP) -> void
4357

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

47-
# Returns initial credential (string) and a proc that accepts a challenge and returns the response
48-
# Use Array[untyped] here to avoid tuple syntax issues in some linters; the runtime value
49-
# is commonly a two-element array [initial_credential, proc].
61+
# Returns initial credential and a proc that accepts a challenge and returns the response
5062
def sasl_bind_setup_digest_md5: (?Hash[Symbol, untyped]) -> Array[untyped]
5163
def sasl_bind_setup_gss_spnego: (?Hash[Symbol, untyped]) -> Array[untyped]
64+
65+
@try_sasl: bool?
66+
@allow_anonymous: bool?
67+
@tls_options: Hash[untyped, untyped]?
68+
@sasl_mechanisms: Array[String]?
69+
@disable_verify_certificates: bool?
5270
end
5371
end
5472
end

sig/omniauth/strategies/ldap.rbs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,12 @@ module OmniAuth
2727

2828
# Perform a directory lookup for a given username; returns an Entry or nil
2929
def directory_lookup: (OmniAuth::LDAP::Adaptor, String) -> untyped
30+
31+
def uid: () { () -> String } -> void
32+
33+
def info: () { () -> Hash[untyped, untyped] } -> void
34+
35+
def extra: () { () -> Hash[Symbol, untyped] } -> void
3036
end
3137
end
3238
end

sig/rbs/net-ldap.rbs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ module Net
55
def open: () { (self) -> untyped } -> untyped
66
def search: (?Hash[Symbol, untyped]) -> Array[Net::LDAP::Entry]
77
def bind: (?Hash[Symbol, untyped]) -> bool
8+
def get_operation_result: () -> Net::LDAP::PDU
89
end
910

1011
class LDAP::Entry
@@ -15,5 +16,20 @@ module Net
1516
def self.construct: (String) -> Net::LDAP::Filter
1617
def self.eq: (String, String) -> Net::LDAP::Filter
1718
end
18-
end
1919

20+
class LDAP::Control
21+
def initialize: (String, bool, untyped) -> void
22+
def oid: () -> String
23+
end
24+
25+
module LDAP::Controls
26+
class PasswordPolicy
27+
def initialize: () -> void
28+
def oid: () -> String
29+
end
30+
end
31+
32+
class LDAP::PDU
33+
def controls: () -> Array[untyped]
34+
end
35+
end

sig/rbs/net-ntlm.rbs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ module Net
44
class Message
55
def self.parse: (untyped) -> Net::NTLM::Message
66
def response: (?Hash[Symbol, untyped], ?Hash[Symbol, untyped]) -> Net::NTLM::Message
7+
# writer used by adaptor to set target name when a domain is present
8+
def target_name=: (String) -> String
79
end
810

911
class Message::Type1
@@ -13,4 +15,3 @@ module Net
1315
def self.encode_utf16le: (String) -> String
1416
end
1517
end
16-

spec/omniauth-ldap/adaptor_spec.rb

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,5 +216,30 @@
216216
expect(adaptor.connection).to receive(:bind).and_return(true)
217217
expect(adaptor.bind_as(args)).to eq rs
218218
end
219+
220+
context "when password policy is enabled" do
221+
let(:ppolicy_oid) { "1.3.6.1.4.1.42.2.27.8.5.1" }
222+
223+
it "adds a Password Policy request control to the bind" do
224+
adaptor = described_class.new({host: "127.0.0.1", encryption: "plain", base: "dc=example, dc=com", port: 389, uid: "sAMAccountName", bind_dn: "bind_dn", password: "password", password_policy: true})
225+
expect(adaptor.connection).to receive(:open).and_yield(adaptor.connection)
226+
expect(adaptor.connection).to receive(:search).with(args).and_return([rs])
227+
expect(adaptor.connection).to receive(:bind) do |bind_args|
228+
expect(bind_args).to be_a(Hash)
229+
expect(bind_args[:controls]).to be_a(Array)
230+
ctrl = bind_args[:controls].first
231+
oid = ctrl.respond_to?(:oid) ? ctrl.oid : ctrl[:oid]
232+
expect(oid).to eq(ppolicy_oid)
233+
true
234+
end.and_return(true)
235+
# Stub operation result with a ppolicy response control
236+
ctrl = Struct.new(:oid).new(ppolicy_oid)
237+
op_result = Struct.new(:controls).new([ctrl])
238+
allow(adaptor.connection).to receive(:get_operation_result).and_return(op_result)
239+
240+
expect(adaptor.bind_as(args)).to eq rs
241+
expect(adaptor.last_password_policy_response).not_to be_nil
242+
end
243+
end
219244
end
220245
end

0 commit comments

Comments
 (0)