diff --git a/.rubocop_gradual.lock b/.rubocop_gradual.lock index f8d88ab..319a63f 100644 --- a/.rubocop_gradual.lock +++ b/.rubocop_gradual.lock @@ -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], @@ -15,14 +12,10 @@ [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], @@ -30,14 +23,14 @@ [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] ] } diff --git a/CHANGELOG.md b/CHANGELOG.md index ca85c0b..5f3808d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index b525090..f10956e 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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 ``` diff --git a/lib/omniauth-ldap/adaptor.rb b/lib/omniauth-ldap/adaptor.rb index 23cb13a..3f78d51 100644 --- a/lib/omniauth-ldap/adaptor.rb +++ b/lib/omniauth-ldap/adaptor.rb @@ -31,6 +31,7 @@ class ConnectionError < StandardError; end :allow_anonymous, :filter, :tls_options, + :password_policy, # Deprecated :method, @@ -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 = [] @@ -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 @@ -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 diff --git a/lib/omniauth/strategies/ldap.rb b/lib/omniauth/strategies/ldap.rb index 5ec29fa..d274241 100644 --- a/lib/omniauth/strategies/ldap.rb +++ b/lib/omniauth/strategies/ldap.rb @@ -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 @@ -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 diff --git a/sig/omniauth-ldap.rbs b/sig/omniauth-ldap.rbs index 399b294..4f77390 100644 --- a/sig/omniauth-ldap.rbs +++ b/sig/omniauth-ldap.rbs @@ -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 diff --git a/sig/omniauth/ldap/adaptor.rbs b/sig/omniauth/ldap/adaptor.rbs index accee0e..39be820 100644 --- a/sig/omniauth/ldap/adaptor.rbs +++ b/sig/omniauth/ldap/adaptor.rbs @@ -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? @@ -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 @@ -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 diff --git a/sig/omniauth/strategies/ldap.rbs b/sig/omniauth/strategies/ldap.rbs index 58be1f6..c73a62c 100644 --- a/sig/omniauth/strategies/ldap.rbs +++ b/sig/omniauth/strategies/ldap.rbs @@ -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 diff --git a/sig/rbs/net-ldap.rbs b/sig/rbs/net-ldap.rbs index edc8f02..91b267d 100644 --- a/sig/rbs/net-ldap.rbs +++ b/sig/rbs/net-ldap.rbs @@ -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 @@ -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 diff --git a/sig/rbs/net-ntlm.rbs b/sig/rbs/net-ntlm.rbs index a6e898f..c87ce7a 100644 --- a/sig/rbs/net-ntlm.rbs +++ b/sig/rbs/net-ntlm.rbs @@ -4,6 +4,8 @@ module Net class Message def self.parse: (untyped) -> Net::NTLM::Message def response: (?Hash[Symbol, untyped], ?Hash[Symbol, untyped]) -> Net::NTLM::Message + # writer used by adaptor to set target name when a domain is present + def target_name=: (String) -> String end class Message::Type1 @@ -13,4 +15,3 @@ module Net def self.encode_utf16le: (String) -> String end end - diff --git a/spec/omniauth-ldap/adaptor_spec.rb b/spec/omniauth-ldap/adaptor_spec.rb index 57feb7d..c38bd0f 100644 --- a/spec/omniauth-ldap/adaptor_spec.rb +++ b/spec/omniauth-ldap/adaptor_spec.rb @@ -203,18 +203,98 @@ it "binds simple" do 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"}) - expect(adaptor.connection).to receive(:open).and_yield(adaptor.connection) - expect(adaptor.connection).to receive(:search).with(args).and_return([rs]) - expect(adaptor.connection).to receive(:bind).with({username: "new dn", password: args[:password], method: :simple}).and_return(true) + allow(adaptor.connection).to receive(:open).and_yield(adaptor.connection) + allow(adaptor.connection).to receive(:search).with(args).and_return([rs]) + allow(adaptor.connection).to receive(:bind).with({username: "new dn", password: args[:password], method: :simple}).and_return(true) expect(adaptor.bind_as(args)).to eq rs end it "binds sasl" do 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"}) - expect(adaptor.connection).to receive(:open).and_yield(adaptor.connection) - expect(adaptor.connection).to receive(:search).with(args).and_return([rs]) - expect(adaptor.connection).to receive(:bind).and_return(true) + allow(adaptor.connection).to receive(:open).and_yield(adaptor.connection) + allow(adaptor.connection).to receive(:search).with(args).and_return([rs]) + allow(adaptor.connection).to receive(:bind).and_return(true) expect(adaptor.bind_as(args)).to eq rs end + + context "when password policy is enabled" do + let(:ppolicy_oid) { "1.3.6.1.4.1.42.2.27.8.5.1" } + + it "adds a Password Policy request control to the bind" do + 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}) + expect(adaptor.connection).to receive(:open).and_yield(adaptor.connection) + expect(adaptor.connection).to receive(:search).with(args).and_return([rs]) + expect(adaptor.connection).to receive(:bind) do |bind_args| + expect(bind_args).to be_a(Hash) + expect(bind_args[:controls]).to be_a(Array) + ctrl = bind_args[:controls].first + oid = ctrl.respond_to?(:oid) ? ctrl.oid : ctrl[:oid] + expect(oid).to eq(ppolicy_oid) + true + end.and_return(true) + # Stub operation result with a ppolicy response control + ctrl = Struct.new(:oid).new(ppolicy_oid) + op_result = Struct.new(:controls).new([ctrl]) + allow(adaptor.connection).to receive(:get_operation_result).and_return(op_result) + + expect(adaptor.bind_as(args)).to eq rs + expect(adaptor.last_password_policy_response).not_to be_nil + end + end + end + + describe "password policy support" do + let(:args) { {filter: Net::LDAP::Filter.eq("sAMAccountName", "u"), password: "p", size: 1} } + let(:entry) { Struct.new(:dn).new("cn=u,dc=example,dc=com") } + let(:ppolicy_oid) { "1.3.6.1.4.1.42.2.27.8.5.1" } + + def mock_conn(opts = {}) + search_result = opts[:search_result] + bind_result = opts.key?(:bind_result) ? opts[:bind_result] : true + op_result_controls = opts[:op_result_controls] || [] + + conn = double("ldap connection") + allow(conn).to receive(:open).and_yield(conn) + allow(conn).to receive(:search).with(args).and_return(search_result) + allow(conn).to receive(:bind) do |bind_args| + @last_bind_args = bind_args + bind_result + end + op_result = Struct.new(:controls).new(op_result_controls) + allow(conn).to receive(:get_operation_result).and_return(op_result) + conn + end + + it "passes a hash control with Password Policy OID and captures response control" do + adaptor = described_class.new(host: "127.0.0.1", port: 389, encryption: "plain", base: "dc=example,dc=com", uid: "sAMAccountName", password_policy: true) + # Response control from server (as a minimal struct exposing oid) + server_ctrl = Struct.new(:oid).new(ppolicy_oid) + adaptor.instance_variable_set(:@connection, mock_conn(search_result: [entry], op_result_controls: [server_ctrl])) + + expect(adaptor.bind_as(args)).to eq entry + expect(@last_bind_args[:controls].first).to include(oid: ppolicy_oid) + expect(adaptor.last_password_policy_response.oid).to eq(ppolicy_oid) + end + + it "handles hash-shaped response controls from server" do + adaptor = described_class.new(host: "127.0.0.1", port: 389, encryption: "plain", base: "dc=example,dc=com", uid: "sAMAccountName", password_policy: true) + hash_ctrl = {oid: ppolicy_oid} + adaptor.instance_variable_set(:@connection, mock_conn(search_result: [entry], op_result_controls: [hash_ctrl])) + + expect(adaptor.bind_as(args)).to eq entry + expect(@last_bind_args[:controls].first).to include(oid: ppolicy_oid) + expect(adaptor.last_password_policy_response).to eq(hash_ctrl) + end + + it "attaches controls for SASL binds" do + 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) + ctrl = Struct.new(:oid).new(ppolicy_oid) + adaptor.instance_variable_set(:@connection, mock_conn(search_result: [entry], op_result_controls: [ctrl])) + + expect(adaptor.bind_as(args)).to eq entry + expect(@last_bind_args).to include(method: :sasl) + expect(@last_bind_args[:controls].first).to include(oid: ppolicy_oid) + expect(adaptor.last_password_policy_response.oid).to eq(ppolicy_oid) + end end end diff --git a/spec/omniauth/strategies/ldap_spec.rb b/spec/omniauth/strategies/ldap_spec.rb index e65160b..92e3efa 100644 --- a/spec/omniauth/strategies/ldap_spec.rb +++ b/spec/omniauth/strategies/ldap_spec.rb @@ -81,6 +81,12 @@ def make_env(path = "/auth/ldap", props = {}) expect(last_response.body.scan("MyLdap Form").size).to be > 1 end + it "redirects to callback when POST includes username and password" do + post "/auth/ldap", {username: "alice", password: "secret"}, {"REQUEST_METHOD" => "POST"} + expect(last_response).to be_redirect + expect(last_response.headers["Location"]).to eq "http://example.org/auth/ldap/callback" + end + context "when mounted under a subdirectory" do let(:sub_env) do make_env("/auth/ldap", { @@ -201,6 +207,39 @@ def make_env(path = "/auth/ldap", props = {}) expect(last_request.env["omniauth.error"].message).to eq("Invalid credentials for ping") end + it "attaches password policy info to env when enabled" do + allow(@adaptor).to receive(:password_policy).and_return(true) + 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") + op = double("op", code: 49, message: "Invalid Credentials") + allow(@adaptor).to receive_messages(last_password_policy_response: ctrl, last_operation_result: op) + + post("/auth/ldap/callback", {username: "ping", password: "password"}) + + expect(last_response).to be_redirect + expect(last_response.headers["Location"]).to match("invalid_credentials") + + policy = last_request.env["omniauth.ldap.password_policy"] + expect(policy).to be_a(Hash) + expect(policy[:error]).to eq(:passwordExpired) + expect(policy[:time_before_expiration]).to eq(42) + expect(policy[:grace_authns_remaining]).to eq(1) + expect(policy[:oid]).to eq("1.3.6.1.4.1.42.2.27.8.5.1") + expect(policy[:operation]).to eq({code: 49, message: "Invalid Credentials"}) + end + + it "maps alternate policy fields (ppolicy_error, grace_logins_remaining)" do + allow(@adaptor).to receive(:password_policy).and_return(true) + ctrl = double("ppolicy", ppolicy_error: :accountLocked, grace_logins_remaining: 0, oid: "1.3.6.1.4.1.42.2.27.8.5.1") + allow(@adaptor).to receive_messages(last_password_policy_response: ctrl, last_operation_result: nil) + + post("/auth/ldap/callback", {username: "ping", password: "password"}) + + expect(last_response).to be_redirect + policy = last_request.env["omniauth.ldap.password_policy"] + expect(policy[:error]).to eq(:accountLocked) + expect(policy[:grace_authns_remaining]).to eq(0) + end + context "and filter is set" do it "binds with filter" do allow(@adaptor).to receive(:filter).and_return("uid=%{username}") @@ -275,6 +314,27 @@ def make_env(path = "/auth/ldap", props = {}) expect(last_response).not_to be_redirect end + it "attaches password policy env on success when enabled" do + allow(@adaptor).to receive(:password_policy).and_return(true) + ctrl = double("ppolicy", oid: "1.3.6.1.4.1.42.2.27.8.5.1") + allow(@adaptor).to receive_messages(last_password_policy_response: ctrl, last_operation_result: nil) + + post("/auth/ldap/callback", {username: "ping", password: "password"}) + + expect(last_response).not_to be_redirect + policy = last_request.env["omniauth.ldap.password_policy"] + expect(policy).to be_a(Hash) + expect(policy[:oid]).to eq("1.3.6.1.4.1.42.2.27.8.5.1") + expect(policy[:raw]).to eq(ctrl) + end + + it "uses equals filter when :filter is not configured" do + allow(@adaptor).to receive(:filter).and_return(nil) + expect(Net::LDAP::Filter).to receive(:equals).with(@adaptor.uid, "ping").and_call_original + post("/auth/ldap/callback", {username: "ping", password: "password"}) + expect(last_response).not_to be_redirect + end + context "and filter is set" do it "binds with filter" do allow(@adaptor).to receive(:filter).and_return("uid=%{username}")