Skip to content

Commit c4d782d

Browse files
authored
Merge pull request #105 from omniauth/feat/draft-behera-ldap-password-policy-11
2 parents 1115b69 + 75864cd commit c4d782d

File tree

12 files changed

+353
-50
lines changed

12 files changed

+353
-50
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

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: 65 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,45 @@ 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+
password = password.call if password.respond_to?(:call)
122+
123+
bind_args = if @bind_method == :sasl
124+
sasl_auths({username: dn, password: password}).first
125+
else
126+
{
127+
method: :simple,
128+
username: dn,
129+
password: password,
130+
}
131+
end
132+
133+
# Optionally request LDAP Password Policy control (RFC Draft - de facto standard)
134+
if @password_policy
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}
137+
if bind_args.is_a?(Hash)
138+
bind_args = bind_args.merge({controls: [control]})
139+
else
140+
# Some Net::LDAP versions allow passing a block for SASL only; ensure we still can add controls if hash
141+
# When not a Hash, we can't merge; rely on server default behavior.
142+
end
143+
end
144+
145+
begin
146+
success = bind_args ? me.bind(bind_args) : me.bind
147+
ensure
148+
capture_password_policy(me)
149+
end
150+
151+
result = rs.first if success
126152
end
127153
end
128154
end
@@ -250,6 +276,32 @@ def symbolize_hash_keys(hash)
250276
result[key.to_sym] = value
251277
end
252278
end
279+
280+
# Capture the operation result and extract any Password Policy response control if present.
281+
def capture_password_policy(conn)
282+
return unless @password_policy
283+
return unless conn.respond_to?(:get_operation_result)
284+
285+
begin
286+
@last_operation_result = conn.get_operation_result
287+
controls = if @last_operation_result && @last_operation_result.respond_to?(:controls)
288+
@last_operation_result.controls || []
289+
else
290+
[]
291+
end
292+
if controls.any?
293+
# Find Password Policy response control by OID
294+
ppolicy_oid = "1.3.6.1.4.1.42.2.27.8.5.1"
295+
ctrl = controls.find do |c|
296+
(c.respond_to?(:oid) && c.oid == ppolicy_oid) || (c.is_a?(Hash) && c[:oid] == ppolicy_oid)
297+
end
298+
@last_password_policy_response = ctrl if ctrl
299+
end
300+
rescue StandardError
301+
# Swallow errors to keep authentication flow unaffected when server or gem doesn't support controls
302+
@last_password_policy_response = nil
303+
end
304+
end
253305
end
254306
end
255307
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

0 commit comments

Comments
 (0)