omniauth-ldap 2.3.1 → 2.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,15 +1,65 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "omniauth"
2
4
  require "omniauth/version"
3
5
 
6
+ # OmniAuth strategies namespace.
7
+ #
8
+ # This file implements an LDAP authentication strategy for OmniAuth.
9
+ # It provides both an interactive request phase (login form) and a
10
+ # callback phase which binds to an LDAP directory to authenticate the
11
+ # user or performs a lookup for header-based SSO.
12
+ #
13
+ # The strategy exposes a number of options (see `option` calls below)
14
+ # that control LDAP connection, mapping of LDAP attributes to the
15
+ # OmniAuth `info` hash, header-based SSO behavior, and SSL/timeouts.
16
+ #
17
+ # @example Minimal Rack mounting
18
+ # use OmniAuth::Builder do
19
+ # provider :ldap, {
20
+ # host: 'ldap.example.com',
21
+ # base: 'dc=example,dc=com'
22
+ # }
23
+ # end
24
+ #
4
25
  module OmniAuth
5
26
  module Strategies
27
+ # LDAP OmniAuth strategy
28
+ #
29
+ # This class implements the OmniAuth::Strategy interface and performs
30
+ # LDAP authentication using an `Adaptor` object. It supports three
31
+ # primary flows:
32
+ #
33
+ # - Interactive login form (request_phase) where users POST username/password
34
+ # - Callback binding where the strategy attempts to bind as the user
35
+ # - Header-based SSO (trusted upstream) where a header identifies the user
36
+ #
37
+ # The mapping from LDAP attributes to resulting `info` fields is
38
+ # configurable via the `:mapping` option. See `map_user` for the
39
+ # mapping algorithm.
40
+ #
41
+ # @see OmniAuth::Strategy
6
42
  class LDAP
43
+ # Whether the loaded OmniAuth version is >= 2.0.0; used to set default request methods.
44
+ # @return [Boolean]
7
45
  OMNIAUTH_GTE_V2 = Gem::Version.new(OmniAuth::VERSION) >= Gem::Version.new("2.0.0")
46
+
8
47
  include OmniAuth::Strategy
9
48
 
49
+ # Raised when credentials are invalid or the user cannot be authenticated.
50
+ # @example
51
+ # raise InvalidCredentialsError, 'Invalid credentials'
10
52
  InvalidCredentialsError = Class.new(StandardError)
11
53
 
12
- CONFIG = {
54
+ # Default mapping for converting LDAP attributes to OmniAuth `info` keys.
55
+ # Keys are the resulting `info` hash keys (strings). Values may be:
56
+ # - String: single LDAP attribute name
57
+ # - Array: list of attribute names in priority order
58
+ # - Hash: pattern mapping where pattern keys contain %<n> placeholders
59
+ # that are substituted from a list of possible attribute names
60
+ #
61
+ # @return [Hash<String, String|Array|Hash>]
62
+ option :mapping, {
13
63
  "name" => "cn",
14
64
  "first_name" => "givenName",
15
65
  "last_name" => "sn",
@@ -23,8 +73,12 @@ module OmniAuth
23
73
  "url" => ["wwwhomepage"],
24
74
  "image" => "jpegPhoto",
25
75
  "description" => "description",
26
- }.freeze
27
- option :title, "LDAP Authentication" # default title for authentication form
76
+ }
77
+
78
+ # Default title shown on the login form.
79
+ # @return [String]
80
+ option :title, "LDAP Authentication"
81
+
28
82
  # For OmniAuth >= 2.0 the default allowed request method is POST only.
29
83
  # Ensure the strategy follows that default so GET /auth/:provider returns 404 as expected in tests.
30
84
  if OMNIAUTH_GTE_V2
@@ -32,6 +86,8 @@ module OmniAuth
32
86
  else
33
87
  option(:request_methods, [:get, :post])
34
88
  end
89
+
90
+ # Default LDAP connection options / behavior
35
91
  option :port, 389
36
92
  option :method, :plain
37
93
  option :disable_verify_certificates, false
@@ -39,6 +95,7 @@ module OmniAuth
39
95
  option :ssl_version, nil # use OpenSSL default if nil
40
96
  option :uid, "sAMAccountName"
41
97
  option :name_proc, lambda { |n| n }
98
+
42
99
  # Trusted header SSO support (disabled by default)
43
100
  # :header_auth - when true and the header is present, the strategy trusts the upstream gateway
44
101
  # and searches the directory for the user without requiring a user password.
@@ -47,6 +104,19 @@ module OmniAuth
47
104
  option :header_auth, false
48
105
  option :header_name, "REMOTE_USER"
49
106
 
107
+ # Optional timeouts (forwarded to Net::LDAP when supported)
108
+ option :connect_timeout, nil
109
+ option :read_timeout, nil
110
+
111
+ # Request phase: Render the login form or redirect to callback for header-auth or direct POSTed credentials
112
+ #
113
+ # This will behave differently depending on OmniAuth version and request method:
114
+ # - For OmniAuth >= 2.0 a GET to /auth/:provider should return 404 (so we return a 404 for GET requests).
115
+ # - If header-based SSO is enabled and a trusted header is present we immediately redirect to the callback.
116
+ # - If credentials are POSTed directly to /auth/:provider we redirect to the callback so the test helpers
117
+ # that populate `env['omniauth.auth']` can operate on the callback request.
118
+ #
119
+ # @return [Array] A Rack response triple from the login form or redirect.
50
120
  def request_phase
51
121
  # OmniAuth >= 2.0 expects the request phase to be POST-only for /auth/:provider.
52
122
  # Some test environments (and OmniAuth itself) enforce this by returning 404 on GET.
@@ -57,24 +127,37 @@ module OmniAuth
57
127
  # Fast-path: if a trusted identity header is present, skip the login form
58
128
  # and jump to the callback where we will complete using directory lookup.
59
129
  if header_username
60
- return Rack::Response.new([], 302, "Location" => callback_path).finish
130
+ return Rack::Response.new([], 302, "Location" => callback_url).finish
61
131
  end
62
132
 
63
133
  # If credentials were POSTed directly to /auth/:provider, redirect to the callback path.
64
134
  # This mirrors the behavior of many OmniAuth providers and allows test helpers (like
65
135
  # OmniAuth::Test::PhonySession) to populate `env['omniauth.auth']` on the callback request.
66
- if request.post? && request.params["username"].to_s != "" && request.params["password"].to_s != ""
67
- return Rack::Response.new([], 302, "Location" => callback_path).finish
136
+ if request.post? && request_data["username"].to_s != "" && request_data["password"].to_s != ""
137
+ return Rack::Response.new([], 302, "Location" => callback_url).finish
68
138
  end
69
139
 
70
140
  OmniAuth::LDAP::Adaptor.validate(@options)
71
- f = OmniAuth::Form.new(title: options[:title] || "LDAP Authentication", url: callback_path)
141
+ f = OmniAuth::Form.new(title: options[:title] || "LDAP Authentication", url: callback_url)
72
142
  f.text_field("Login", "username")
73
143
  f.password_field("Password", "password")
74
144
  f.button("Sign In")
75
145
  f.to_response
76
146
  end
77
147
 
148
+ # Callback phase: Authenticate user or perform header-based lookup
149
+ #
150
+ # This method executes on the callback URL and implements the main
151
+ # authentication logic. There are two primary paths:
152
+ #
153
+ # - Header-based lookup: when `options[:header_auth]` is enabled and a header value is present,
154
+ # we perform a read-only directory lookup for the user and, if found, map attributes and finish.
155
+ # - Password bind: when username/password are provided we attempt a bind as the user using the adaptor.
156
+ #
157
+ # Errors raised by the LDAP adaptor are captured and turned into OmniAuth failures.
158
+ #
159
+ # @raise [InvalidCredentialsError] when credentials are invalid
160
+ # @return [Object] result of calling `super` from the OmniAuth::Strategy chain
78
161
  def callback_phase
79
162
  @adaptor = OmniAuth::LDAP::Adaptor.new(@options)
80
163
 
@@ -88,7 +171,7 @@ module OmniAuth
88
171
  return fail!(:invalid_credentials, InvalidCredentialsError.new("User not found for header #{hu}"))
89
172
  end
90
173
  @ldap_user_info = entry
91
- @user_info = self.class.map_user(CONFIG, @ldap_user_info)
174
+ @user_info = self.class.map_user(@options[:mapping], @ldap_user_info)
92
175
  return super
93
176
  rescue => e
94
177
  return fail!(:ldap_error, e)
@@ -97,39 +180,74 @@ module OmniAuth
97
180
 
98
181
  return fail!(:missing_credentials) if missing_credentials?
99
182
  begin
100
- @ldap_user_info = @adaptor.bind_as(filter: filter(@adaptor), size: 1, password: request.params["password"])
183
+ @ldap_user_info = @adaptor.bind_as(filter: filter(@adaptor), size: 1, password: request_data["password"])
101
184
 
102
185
  unless @ldap_user_info
103
- return fail!(:invalid_credentials, InvalidCredentialsError.new("Invalid credentials for #{request.params["username"]}"))
186
+ # Attach password policy info to env if available (best-effort)
187
+ attach_password_policy_env(@adaptor)
188
+ return fail!(:invalid_credentials, InvalidCredentialsError.new("Invalid credentials for #{request_data["username"]}"))
104
189
  end
105
190
 
106
- @user_info = self.class.map_user(CONFIG, @ldap_user_info)
191
+ # Optionally attach policy info even on success (e.g., timeBeforeExpiration)
192
+ attach_password_policy_env(@adaptor)
193
+
194
+ @user_info = self.class.map_user(@options[:mapping], @ldap_user_info)
107
195
  super
108
196
  rescue => e
109
197
  fail!(:ldap_error, e)
110
198
  end
111
199
  end
112
200
 
201
+ # Build an LDAP filter for searching/binding the user.
202
+ #
203
+ # If the adaptor has a custom `filter` option set it will be used (with
204
+ # interpolation of `%{username}`). Otherwise a simple equality filter for
205
+ # the configured uid attribute is used.
206
+ #
207
+ # @param adaptor [OmniAuth::LDAP::Adaptor] the adaptor used to build connection/filters
208
+ # @param username_override [String, nil] optional username to build the filter for (defaults to request username)
209
+ # @return [Net::LDAP::Filter] the constructed filter object
113
210
  def filter(adaptor, username_override = nil)
114
- if adaptor.filter && !adaptor.filter.empty?
115
- username = Net::LDAP::Filter.escape(@options[:name_proc].call(username_override || request.params["username"]))
116
- Net::LDAP::Filter.construct(adaptor.filter % {username: username})
211
+ flt = adaptor.filter
212
+ if flt && !flt.to_s.empty?
213
+ username = Net::LDAP::Filter.escape(@options[:name_proc].call(username_override || request_data["username"]))
214
+ Net::LDAP::Filter.construct(flt % {username: username})
117
215
  else
118
- Net::LDAP::Filter.equals(adaptor.uid, @options[:name_proc].call(username_override || request.params["username"]))
216
+ Net::LDAP::Filter.equals(adaptor.uid, @options[:name_proc].call(username_override || request_data["username"]))
119
217
  end
120
218
  end
121
219
 
122
- uid {
123
- @user_info["uid"]
124
- }
125
- info {
126
- @user_info
127
- }
128
- extra {
129
- {raw_info: @ldap_user_info}
130
- }
220
+ # The uid exposed to OmniAuth consumers.
221
+ #
222
+ # This block-based DSL is part of OmniAuth::Strategy; document the value
223
+ # returned by the block.
224
+ #
225
+ # @return [String] the user's uid as determined from the mapped info
226
+ uid { @user_info["uid"] }
227
+
228
+ # The `info` hash returned to OmniAuth consumers. Usually contains name, email, etc.
229
+ # @return [Hash<String, Object>]
230
+ info { @user_info }
231
+
232
+ # Extra information exposed under `extra[:raw_info]` containing the raw LDAP entry.
233
+ # @return [Hash{Symbol => Object}]
234
+ extra { {raw_info: @ldap_user_info} }
131
235
 
132
236
  class << self
237
+ # Map LDAP attributes from the directory entry into a simple Hash used
238
+ # for the OmniAuth `info` hash according to the provided `mapper`.
239
+ #
240
+ # The mapper supports three types of values:
241
+ # - String: a single attribute name. The method will call the attribute
242
+ # reader (downcased symbol) on the `object` and take the first value.
243
+ # - Array: iterate values and pick the first attribute that exists on the object.
244
+ # - Hash: a mapping of a pattern string to an array of attribute-name lists
245
+ # where each `%<n>` placeholder in the pattern will be substituted by the
246
+ # first available attribute from the corresponding list.
247
+ #
248
+ # @param mapper [Hash] mapping configuration (see option :mapping)
249
+ # @param object [#respond_to?, #[]] directory entry (commonly a Net::LDAP::Entry or similar)
250
+ # @return [Hash<String, Object>] the mapped user info hash
133
251
  def map_user(mapper, object)
134
252
  user = {}
135
253
  mapper.each do |key, value|
@@ -168,16 +286,38 @@ module OmniAuth
168
286
 
169
287
  protected
170
288
 
289
+ # Validate that the incoming request method is allowed.
290
+ #
291
+ # For OmniAuth >= 2.0 the default is POST only. This method checks the
292
+ # Rack env REQUEST_METHOD directly so tests and environments that stub
293
+ # request.HTTP_METHOD are handled deterministically.
294
+ #
295
+ # @return [Boolean] true when the request method is POST
171
296
  def valid_request_method?
172
297
  request.env["REQUEST_METHOD"] == "POST"
173
298
  end
174
299
 
300
+ # Determine if the request is missing required credentials.
301
+ #
302
+ # @return [Boolean] true when username or password are nil/empty
175
303
  def missing_credentials?
176
- request.params["username"].nil? || request.params["username"].empty? || request.params["password"].nil? || request.params["password"].empty?
177
- end # missing_credentials?
304
+ request_data["username"].nil? || request_data["username"].empty? || request_data["password"].nil? || request_data["password"].empty?
305
+ end
306
+
307
+ # Extract request parameters in a way compatible with Rails/Rack.
308
+ #
309
+ # @return [Hash] parameters hash containing at least "username" and "password" when provided
310
+ def request_data
311
+ @env["action_dispatch.request.request_parameters"] || request.params
312
+ end
178
313
 
179
314
  # Extract a normalized username from a trusted header when enabled.
180
315
  # Returns nil when not configured or not present.
316
+ #
317
+ # The method will attempt the raw env key (e.g. "REMOTE_USER") and the Rack
318
+ # HTTP_ variant (e.g. "HTTP_REMOTE_USER" or "HTTP_X_REMOTE_USER").
319
+ #
320
+ # @return [String, nil] normalized username or nil if not present
181
321
  def header_username
182
322
  return unless options[:header_auth]
183
323
 
@@ -191,15 +331,69 @@ module OmniAuth
191
331
 
192
332
  # Perform a directory lookup for the given username using the strategy configuration
193
333
  # (bind_dn/password or anonymous). Does not attempt to bind as the user.
334
+ #
335
+ # @param adaptor [OmniAuth::LDAP::Adaptor] initialized adaptor
336
+ # @param username [String] username to look up
337
+ # @return [Object, nil] first directory entry found or nil
194
338
  def directory_lookup(adaptor, username)
195
339
  entry = nil
196
- filter = filter(adaptor, username)
340
+ search_filter = filter(adaptor, username)
197
341
  adaptor.connection.open do |conn|
198
- rs = conn.search(filter: filter, size: 1)
199
- entry = rs.first if rs && rs.first
342
+ rs = conn.search(filter: search_filter, size: 1)
343
+ entry = rs && rs.first
200
344
  end
201
345
  entry
202
346
  end
347
+
348
+ # If the adaptor captured a Password Policy response control, expose a minimal, stable hash
349
+ # in the Rack env for applications to inspect.
350
+ #
351
+ # The structure is available at `request.env['omniauth.ldap.password_policy']`.
352
+ #
353
+ # @param adaptor [OmniAuth::LDAP::Adaptor]
354
+ # @return [void]
355
+ def attach_password_policy_env(adaptor)
356
+ return unless adaptor.respond_to?(:password_policy) && adaptor.password_policy
357
+ ctrl = adaptor.respond_to?(:last_password_policy_response) ? adaptor.last_password_policy_response : nil
358
+ op = adaptor.respond_to?(:last_operation_result) ? adaptor.last_operation_result : nil
359
+ return unless ctrl || op
360
+
361
+ request.env["omniauth.ldap.password_policy"] = extract_password_policy(ctrl, op)
362
+ end
363
+
364
+ # Best-effort extraction across net-ldap versions; if fields are not available, returns a raw payload.
365
+ #
366
+ # @param control [Object, nil] the password policy response control if available
367
+ # @param operation [Object, nil] the last operation result if available
368
+ # @return [Hash] normalized password policy info with keys :raw, :error, :time_before_expiration, :grace_authns_remaining, :oid, :operation
369
+ def extract_password_policy(control, operation)
370
+ data = {raw: control}
371
+ if control
372
+ # Prefer named readers if present
373
+ if control.respond_to?(:error)
374
+ data[:error] = control.public_send(:error)
375
+ elsif control.respond_to?(:ppolicy_error)
376
+ data[:error] = control.public_send(:ppolicy_error)
377
+ end
378
+ if control.respond_to?(:time_before_expiration)
379
+ data[:time_before_expiration] = control.public_send(:time_before_expiration)
380
+ end
381
+ if control.respond_to?(:grace_authns_remaining)
382
+ data[:grace_authns_remaining] = control.public_send(:grace_authns_remaining)
383
+ elsif control.respond_to?(:grace_logins_remaining)
384
+ data[:grace_authns_remaining] = control.public_send(:grace_logins_remaining)
385
+ end
386
+ if control.respond_to?(:oid)
387
+ data[:oid] = control.public_send(:oid)
388
+ end
389
+ end
390
+ if operation
391
+ code = operation.respond_to?(:code) ? operation.code : nil
392
+ message = operation.respond_to?(:message) ? operation.message : nil
393
+ data[:operation] = {code: code, message: message}
394
+ end
395
+ data
396
+ end
203
397
  end
204
398
  end
205
399
  end