Skip to content

Commit 75864cd

Browse files
committed
1 parent 12b2ff6 commit 75864cd

File tree

5 files changed

+143
-46
lines changed

5 files changed

+143
-46
lines changed

.rubocop_gradual.lock

Lines changed: 14 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,6 @@
11
{
2-
"lib/omniauth-ldap/adaptor.rb:715274645": [
3-
[64, 7, 413, "Style/ClassMethodsDefinitions: Use `class << self` to define a class method.", 105664470],
4-
[114, 17, 3, "Style/AndOr: Use `&&` instead of `and`.", 193409806],
5-
[114, 30, 3, "Style/AndOr: Use `&&` instead of `and`.", 193409806],
6-
[114, 37, 1, "Lint/AssignmentInCondition: Wrap assignment in parentheses if intentional", 177560]
2+
"lib/omniauth-ldap/adaptor.rb:3212021924": [
3+
[65, 7, 413, "Style/ClassMethodsDefinitions: Use `class << self` to define a class method.", 105664470]
74
],
85
"spec/integration/middleware_spec.rb:4142891586": [
96
[3, 16, 39, "RSpec/DescribeClass: The first argument to describe should be the class or module being tested.", 638096201],
@@ -15,29 +12,25 @@
1512
[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],
1613
[70, 16, 5, "RSpec/ExpectActual: Provide the actual value you are testing to `expect(...)`.", 237881235]
1714
],
18-
"spec/omniauth-ldap/adaptor_spec.rb:2715031579": [
15+
"spec/omniauth-ldap/adaptor_spec.rb:321639549": [
1916
[3, 1, 38, "RSpec/SpecFilePathFormat: Spec path should end with `omni_auth/ldap/adaptor*_spec.rb`.", 1973618936],
20-
[206, 7, 26, "RSpec/StubbedMock: Prefer `allow` over `expect` when configuring a response.", 1924417310],
21-
[207, 7, 26, "RSpec/StubbedMock: Prefer `allow` over `expect` when configuring a response.", 1924417310],
22-
[208, 7, 26, "RSpec/StubbedMock: Prefer `allow` over `expect` when configuring a response.", 1924417310],
23-
[214, 7, 26, "RSpec/StubbedMock: Prefer `allow` over `expect` when configuring a response.", 1924417310],
24-
[215, 7, 26, "RSpec/StubbedMock: Prefer `allow` over `expect` when configuring a response.", 1924417310],
25-
[216, 7, 26, "RSpec/StubbedMock: Prefer `allow` over `expect` when configuring a response.", 1924417310]
17+
[225, 9, 26, "RSpec/StubbedMock: Prefer `allow` over `expect` when configuring a response.", 1924417310],
18+
[226, 9, 26, "RSpec/StubbedMock: Prefer `allow` over `expect` when configuring a response.", 1924417310]
2619
],
2720
"spec/omniauth/adaptor_spec.rb:1168013709": [
2821
[3, 1, 38, "RSpec/SpecFilePathFormat: Spec path should end with `omni_auth/ldap/adaptor*_spec.rb`.", 1973618936],
2922
[46, 7, 38, "RSpec/AnyInstance: Avoid stubbing using `allow_any_instance_of`.", 3627954156],
3023
[47, 7, 38, "RSpec/AnyInstance: Avoid stubbing using `allow_any_instance_of`.", 3627954156],
3124
[84, 7, 48, "RSpec/AnyInstance: Avoid stubbing using `allow_any_instance_of`.", 2759780562]
3225
],
33-
"spec/omniauth/strategies/ldap_spec.rb:2044523926": [
34-
[120, 13, 9, "RSpec/ContextWording: Context description should match /^when\\b/, /^with\\b/, or /^without\\b/.", 1130140517],
35-
[175, 17, 28, "RSpec/ContextWording: Context description should match /^when\\b/, /^with\\b/, or /^without\\b/.", 3444838747],
36-
[184, 17, 23, "RSpec/ContextWording: Context description should match /^when\\b/, /^with\\b/, or /^without\\b/.", 1584148894],
37-
[195, 17, 32, "RSpec/ContextWording: Context description should match /^when\\b/, /^with\\b/, or /^without\\b/.", 1515076977],
38-
[204, 19, 19, "RSpec/ContextWording: Context description should match /^when\\b/, /^with\\b/, or /^without\\b/.", 2526348694],
39-
[230, 17, 56, "RSpec/ContextWording: Context description should match /^when\\b/, /^with\\b/, or /^without\\b/.", 2413495789],
40-
[245, 13, 9, "RSpec/ContextWording: Context description should match /^when\\b/, /^with\\b/, or /^without\\b/.", 3182939526],
41-
[278, 15, 19, "RSpec/ContextWording: Context description should match /^when\\b/, /^with\\b/, or /^without\\b/.", 2526348694]
26+
"spec/omniauth/strategies/ldap_spec.rb:1834231975": [
27+
[126, 13, 9, "RSpec/ContextWording: Context description should match /^when\\b/, /^with\\b/, or /^without\\b/.", 1130140517],
28+
[181, 17, 28, "RSpec/ContextWording: Context description should match /^when\\b/, /^with\\b/, or /^without\\b/.", 3444838747],
29+
[190, 17, 23, "RSpec/ContextWording: Context description should match /^when\\b/, /^with\\b/, or /^without\\b/.", 1584148894],
30+
[201, 17, 32, "RSpec/ContextWording: Context description should match /^when\\b/, /^with\\b/, or /^without\\b/.", 1515076977],
31+
[243, 19, 19, "RSpec/ContextWording: Context description should match /^when\\b/, /^with\\b/, or /^without\\b/.", 2526348694],
32+
[269, 17, 56, "RSpec/ContextWording: Context description should match /^when\\b/, /^with\\b/, or /^without\\b/.", 2413495789],
33+
[284, 13, 9, "RSpec/ContextWording: Context description should match /^when\\b/, /^with\\b/, or /^without\\b/.", 3182939526],
34+
[338, 15, 19, "RSpec/ContextWording: Context description should match /^when\\b/, /^with\\b/, or /^without\\b/.", 2526348694]
4235
]
4336
}

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ Please file a bug if you notice a violation of semantic versioning.
2525
- Support for SCRIPT_NAME for proper URL generation
2626
- behind certain proxies/load balancers, or
2727
- under a subdirectory
28+
- Password Policy for LDAP Directories
29+
- password_policy: true|false (default: false)
30+
- on authentication failure, if the server returns password policy controls, the info will be included in the failure message
31+
- https://datatracker.ietf.org/doc/html/draft-behera-ldap-password-policy-11
2832

2933
### Changed
3034

lib/omniauth-ldap/adaptor.rb

Lines changed: 4 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -118,10 +118,9 @@ def bind_as(args = {})
118118
dn = rs.first.dn
119119
if dn
120120
password = args[:password]
121-
method = args[:method] || @method
122121
password = password.call if password.respond_to?(:call)
123122

124-
bind_args = if method == "sasl"
123+
bind_args = if @bind_method == :sasl
125124
sasl_auths({username: dn, password: password}).first
126125
else
127126
{
@@ -133,27 +132,13 @@ def bind_as(args = {})
133132

134133
# Optionally request LDAP Password Policy control (RFC Draft - de facto standard)
135134
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
135+
# Always request by OID using a simple hash; avoids depending on gem-specific control classes
136+
control = {oid: "1.3.6.1.4.1.42.2.27.8.5.1", criticality: true, value: nil}
152137
if bind_args.is_a?(Hash)
153138
bind_args = bind_args.merge({controls: [control]})
154139
else
155140
# 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.
141+
# When not a Hash, we can't merge; rely on server default behavior.
157142
end
158143
end
159144

spec/omniauth-ldap/adaptor_spec.rb

Lines changed: 61 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -203,17 +203,17 @@
203203

204204
it "binds simple" do
205205
adaptor = described_class.new({host: "192.168.1.126", encryption: "plain", base: "dc=score, dc=local", port: 389, uid: "sAMAccountName", bind_dn: "bind_dn", password: "password"})
206-
expect(adaptor.connection).to receive(:open).and_yield(adaptor.connection)
207-
expect(adaptor.connection).to receive(:search).with(args).and_return([rs])
208-
expect(adaptor.connection).to receive(:bind).with({username: "new dn", password: args[:password], method: :simple}).and_return(true)
206+
allow(adaptor.connection).to receive(:open).and_yield(adaptor.connection)
207+
allow(adaptor.connection).to receive(:search).with(args).and_return([rs])
208+
allow(adaptor.connection).to receive(:bind).with({username: "new dn", password: args[:password], method: :simple}).and_return(true)
209209
expect(adaptor.bind_as(args)).to eq rs
210210
end
211211

212212
it "binds sasl" do
213213
adaptor = described_class.new({host: "192.168.1.145", encryption: "plain", base: "dc=intridea, dc=com", port: 389, uid: "sAMAccountName", try_sasl: true, sasl_mechanisms: ["GSS-SPNEGO"], bind_dn: "bind_dn", password: "password"})
214-
expect(adaptor.connection).to receive(:open).and_yield(adaptor.connection)
215-
expect(adaptor.connection).to receive(:search).with(args).and_return([rs])
216-
expect(adaptor.connection).to receive(:bind).and_return(true)
214+
allow(adaptor.connection).to receive(:open).and_yield(adaptor.connection)
215+
allow(adaptor.connection).to receive(:search).with(args).and_return([rs])
216+
allow(adaptor.connection).to receive(:bind).and_return(true)
217217
expect(adaptor.bind_as(args)).to eq rs
218218
end
219219

@@ -242,4 +242,59 @@
242242
end
243243
end
244244
end
245+
246+
describe "password policy support" do
247+
let(:args) { {filter: Net::LDAP::Filter.eq("sAMAccountName", "u"), password: "p", size: 1} }
248+
let(:entry) { Struct.new(:dn).new("cn=u,dc=example,dc=com") }
249+
let(:ppolicy_oid) { "1.3.6.1.4.1.42.2.27.8.5.1" }
250+
251+
def mock_conn(opts = {})
252+
search_result = opts[:search_result]
253+
bind_result = opts.key?(:bind_result) ? opts[:bind_result] : true
254+
op_result_controls = opts[:op_result_controls] || []
255+
256+
conn = double("ldap connection")
257+
allow(conn).to receive(:open).and_yield(conn)
258+
allow(conn).to receive(:search).with(args).and_return(search_result)
259+
allow(conn).to receive(:bind) do |bind_args|
260+
@last_bind_args = bind_args
261+
bind_result
262+
end
263+
op_result = Struct.new(:controls).new(op_result_controls)
264+
allow(conn).to receive(:get_operation_result).and_return(op_result)
265+
conn
266+
end
267+
268+
it "passes a hash control with Password Policy OID and captures response control" do
269+
adaptor = described_class.new(host: "127.0.0.1", port: 389, encryption: "plain", base: "dc=example,dc=com", uid: "sAMAccountName", password_policy: true)
270+
# Response control from server (as a minimal struct exposing oid)
271+
server_ctrl = Struct.new(:oid).new(ppolicy_oid)
272+
adaptor.instance_variable_set(:@connection, mock_conn(search_result: [entry], op_result_controls: [server_ctrl]))
273+
274+
expect(adaptor.bind_as(args)).to eq entry
275+
expect(@last_bind_args[:controls].first).to include(oid: ppolicy_oid)
276+
expect(adaptor.last_password_policy_response.oid).to eq(ppolicy_oid)
277+
end
278+
279+
it "handles hash-shaped response controls from server" do
280+
adaptor = described_class.new(host: "127.0.0.1", port: 389, encryption: "plain", base: "dc=example,dc=com", uid: "sAMAccountName", password_policy: true)
281+
hash_ctrl = {oid: ppolicy_oid}
282+
adaptor.instance_variable_set(:@connection, mock_conn(search_result: [entry], op_result_controls: [hash_ctrl]))
283+
284+
expect(adaptor.bind_as(args)).to eq entry
285+
expect(@last_bind_args[:controls].first).to include(oid: ppolicy_oid)
286+
expect(adaptor.last_password_policy_response).to eq(hash_ctrl)
287+
end
288+
289+
it "attaches controls for SASL binds" do
290+
adaptor = described_class.new(host: "127.0.0.1", port: 389, encryption: "plain", base: "dc=example,dc=com", uid: "sAMAccountName", try_sasl: true, sasl_mechanisms: ["DIGEST-MD5"], bind_dn: "bind_dn", password: "bind_pw", password_policy: true)
291+
ctrl = Struct.new(:oid).new(ppolicy_oid)
292+
adaptor.instance_variable_set(:@connection, mock_conn(search_result: [entry], op_result_controls: [ctrl]))
293+
294+
expect(adaptor.bind_as(args)).to eq entry
295+
expect(@last_bind_args).to include(method: :sasl)
296+
expect(@last_bind_args[:controls].first).to include(oid: ppolicy_oid)
297+
expect(adaptor.last_password_policy_response.oid).to eq(ppolicy_oid)
298+
end
299+
end
245300
end

spec/omniauth/strategies/ldap_spec.rb

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,12 @@ def make_env(path = "/auth/ldap", props = {})
8181
expect(last_response.body.scan("MyLdap Form").size).to be > 1
8282
end
8383

84+
it "redirects to callback when POST includes username and password" do
85+
post "/auth/ldap", {username: "alice", password: "secret"}, {"REQUEST_METHOD" => "POST"}
86+
expect(last_response).to be_redirect
87+
expect(last_response.headers["Location"]).to eq "http://example.org/auth/ldap/callback"
88+
end
89+
8490
context "when mounted under a subdirectory" do
8591
let(:sub_env) do
8692
make_env("/auth/ldap", {
@@ -201,6 +207,39 @@ def make_env(path = "/auth/ldap", props = {})
201207
expect(last_request.env["omniauth.error"].message).to eq("Invalid credentials for ping")
202208
end
203209

210+
it "attaches password policy info to env when enabled" do
211+
allow(@adaptor).to receive(:password_policy).and_return(true)
212+
ctrl = double("ppolicy", error: :passwordExpired, time_before_expiration: 42, grace_authns_remaining: 1, oid: "1.3.6.1.4.1.42.2.27.8.5.1")
213+
op = double("op", code: 49, message: "Invalid Credentials")
214+
allow(@adaptor).to receive_messages(last_password_policy_response: ctrl, last_operation_result: op)
215+
216+
post("/auth/ldap/callback", {username: "ping", password: "password"})
217+
218+
expect(last_response).to be_redirect
219+
expect(last_response.headers["Location"]).to match("invalid_credentials")
220+
221+
policy = last_request.env["omniauth.ldap.password_policy"]
222+
expect(policy).to be_a(Hash)
223+
expect(policy[:error]).to eq(:passwordExpired)
224+
expect(policy[:time_before_expiration]).to eq(42)
225+
expect(policy[:grace_authns_remaining]).to eq(1)
226+
expect(policy[:oid]).to eq("1.3.6.1.4.1.42.2.27.8.5.1")
227+
expect(policy[:operation]).to eq({code: 49, message: "Invalid Credentials"})
228+
end
229+
230+
it "maps alternate policy fields (ppolicy_error, grace_logins_remaining)" do
231+
allow(@adaptor).to receive(:password_policy).and_return(true)
232+
ctrl = double("ppolicy", ppolicy_error: :accountLocked, grace_logins_remaining: 0, oid: "1.3.6.1.4.1.42.2.27.8.5.1")
233+
allow(@adaptor).to receive_messages(last_password_policy_response: ctrl, last_operation_result: nil)
234+
235+
post("/auth/ldap/callback", {username: "ping", password: "password"})
236+
237+
expect(last_response).to be_redirect
238+
policy = last_request.env["omniauth.ldap.password_policy"]
239+
expect(policy[:error]).to eq(:accountLocked)
240+
expect(policy[:grace_authns_remaining]).to eq(0)
241+
end
242+
204243
context "and filter is set" do
205244
it "binds with filter" do
206245
allow(@adaptor).to receive(:filter).and_return("uid=%{username}")
@@ -275,6 +314,27 @@ def make_env(path = "/auth/ldap", props = {})
275314
expect(last_response).not_to be_redirect
276315
end
277316

317+
it "attaches password policy env on success when enabled" do
318+
allow(@adaptor).to receive(:password_policy).and_return(true)
319+
ctrl = double("ppolicy", oid: "1.3.6.1.4.1.42.2.27.8.5.1")
320+
allow(@adaptor).to receive_messages(last_password_policy_response: ctrl, last_operation_result: nil)
321+
322+
post("/auth/ldap/callback", {username: "ping", password: "password"})
323+
324+
expect(last_response).not_to be_redirect
325+
policy = last_request.env["omniauth.ldap.password_policy"]
326+
expect(policy).to be_a(Hash)
327+
expect(policy[:oid]).to eq("1.3.6.1.4.1.42.2.27.8.5.1")
328+
expect(policy[:raw]).to eq(ctrl)
329+
end
330+
331+
it "uses equals filter when :filter is not configured" do
332+
allow(@adaptor).to receive(:filter).and_return(nil)
333+
expect(Net::LDAP::Filter).to receive(:equals).with(@adaptor.uid, "ping").and_call_original
334+
post("/auth/ldap/callback", {username: "ping", password: "password"})
335+
expect(last_response).not_to be_redirect
336+
end
337+
278338
context "and filter is set" do
279339
it "binds with filter" do
280340
allow(@adaptor).to receive(:filter).and_return("uid=%{username}")

0 commit comments

Comments
 (0)