Skip to content

Commit 1c69dc4

Browse files
committed
📝 Document usage of filter config
1 parent 1170a30 commit 1c69dc4

File tree

3 files changed

+140
-12
lines changed

3 files changed

+140
-12
lines changed

.rubocop_gradual.lock

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -30,15 +30,14 @@
3030
[47, 7, 38, "RSpec/AnyInstance: Avoid stubbing using `allow_any_instance_of`.", 3627954156],
3131
[84, 7, 48, "RSpec/AnyInstance: Avoid stubbing using `allow_any_instance_of`.", 2759780562]
3232
],
33-
"spec/omniauth/strategies/ldap_spec.rb:1788355205": [
34-
[14, 3, 54, "RSpec/LeakyConstantDeclaration: Stub class constant instead of declaring explicitly.", 2419068710],
35-
[90, 13, 9, "RSpec/ContextWording: Context description should match /^when\\b/, /^with\\b/, or /^without\\b/.", 1130140517],
36-
[145, 17, 28, "RSpec/ContextWording: Context description should match /^when\\b/, /^with\\b/, or /^without\\b/.", 3444838747],
37-
[154, 17, 23, "RSpec/ContextWording: Context description should match /^when\\b/, /^with\\b/, or /^without\\b/.", 1584148894],
38-
[165, 17, 32, "RSpec/ContextWording: Context description should match /^when\\b/, /^with\\b/, or /^without\\b/.", 1515076977],
39-
[174, 19, 19, "RSpec/ContextWording: Context description should match /^when\\b/, /^with\\b/, or /^without\\b/.", 2526348694],
40-
[187, 17, 56, "RSpec/ContextWording: Context description should match /^when\\b/, /^with\\b/, or /^without\\b/.", 2413495789],
41-
[202, 13, 9, "RSpec/ContextWording: Context description should match /^when\\b/, /^with\\b/, or /^without\\b/.", 3182939526],
42-
[235, 15, 19, "RSpec/ContextWording: Context description should match /^when\\b/, /^with\\b/, or /^without\\b/.", 2526348694]
33+
"spec/omniauth/strategies/ldap_spec.rb:783052937": [
34+
[93, 13, 9, "RSpec/ContextWording: Context description should match /^when\\b/, /^with\\b/, or /^without\\b/.", 1130140517],
35+
[148, 17, 28, "RSpec/ContextWording: Context description should match /^when\\b/, /^with\\b/, or /^without\\b/.", 3444838747],
36+
[157, 17, 23, "RSpec/ContextWording: Context description should match /^when\\b/, /^with\\b/, or /^without\\b/.", 1584148894],
37+
[168, 17, 32, "RSpec/ContextWording: Context description should match /^when\\b/, /^with\\b/, or /^without\\b/.", 1515076977],
38+
[177, 19, 19, "RSpec/ContextWording: Context description should match /^when\\b/, /^with\\b/, or /^without\\b/.", 2526348694],
39+
[203, 17, 56, "RSpec/ContextWording: Context description should match /^when\\b/, /^with\\b/, or /^without\\b/.", 2413495789],
40+
[218, 13, 9, "RSpec/ContextWording: Context description should match /^when\\b/, /^with\\b/, or /^without\\b/.", 3182939526],
41+
[251, 15, 19, "RSpec/ContextWording: Context description should match /^when\\b/, /^with\\b/, or /^without\\b/.", 2526348694]
4342
]
4443
}

README.md

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,66 @@ provider :ldap,
312312
password: ENV["LDAP_SEARCH_PASSWORD"]
313313
```
314314

315+
What `:filter` actually does
316+
317+
- If `:filter` is provided, the strategy constructs an LDAP filter string by substituting `%{username}` with the submitted username after applying `:name_proc`, escaping special characters per RFC 4515, and passes it to the directory search.
318+
- In the normal password flow, a successful search returns the user's DN and we then bind as that DN with the submitted password.
319+
- In trusted header SSO flow (`header_auth: true`), we only perform the search and skip the user password bind; if the search returns no entry, authentication fails.
320+
- If `:filter` is not provided, the strategy falls back to a simple equality filter using `:uid` (e.g. `(uid=alice)`).
321+
322+
Notes on escaping and safety
323+
324+
- We escape the interpolated username with `Net::LDAP::Filter.escape`, which protects against LDAP injection and handles special characters like `(`, `)`, `*`, and `\`.
325+
- Your static filter text is used as-is — keep it to a valid LDAP filter expression and only use `%{username}` for substitution.
326+
327+
Group-based recipes
328+
329+
- Active Directory (simple group):
330+
331+
```text
332+
(&(sAMAccountName=%{username})(memberOf=cn=myapp-users,ou=groups,dc=example,dc=com))
333+
```
334+
335+
- Active Directory (nested groups via matchingRuleInChain):
336+
337+
```text
338+
(&(sAMAccountName=%{username})(memberOf:1.2.840.113556.1.4.1941:=cn=myapp-users,ou=groups,dc=example,dc=com))
339+
```
340+
341+
- OpenLDAP (groupOfNames):
342+
343+
```text
344+
(&(uid=%{username})(memberOf=cn=myapp-users,ou=groups,dc=example,dc=com))
345+
```
346+
347+
or, if you can't use `memberOf` overlays, filter on the group and member DN:
348+
349+
```text
350+
(&(uid=%{username})(|(uniqueMember=uid=%{username},ou=people,dc=example,dc=com)(member=uid=%{username},ou=people,dc=example,dc=com)))
351+
```
352+
353+
Username normalization examples
354+
355+
- If your users sign in with an email but the directory expects a short name, combine `:name_proc` with `:filter`:
356+
357+
```ruby
358+
provider :ldap,
359+
name_proc: proc { |n| n.split("@").first },
360+
filter: "(&(sAMAccountName=%{username})(memberOf=cn=myapp-users,ou=groups,dc=example,dc=com))"
361+
# other settings...
362+
```
363+
364+
Discourse plugin (jonmbake/discourse-ldap-auth)
365+
366+
- That plugin forwards its `filter` setting to this gem. You can therefore paste the same filter strings shown above.
367+
- Example (allow only members of `forum-users`):
368+
369+
```text
370+
(&(uid=%{username})(memberOf=cn=forum-users,ou=groups,dc=example,dc=com))
371+
```
372+
373+
- If users type an email address but your directory matches on a short user id, also configure `name_proc` accordingly in your app (or the plugin, if supported).
374+
315375
### SASL (advanced)
316376

317377
SASL enables alternative bind mechanisms. Only enable if you understand the server-side requirements.
@@ -325,7 +385,7 @@ provider :ldap,
325385
uid: "uid"
326386
```
327387

328-
Supported mechanisms include `"DIGEST-MD5"` and `"GSS-SPNEGO"` depending on your environment and gems.
388+
Supported mechanisms include "DIGEST-MD5" and "GSS-SPNEGO" depending on your environment and gems.
329389

330390
### Name processing and examples
331391

spec/omniauth/strategies/ldap_spec.rb

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@
1111
# :name_proc => Proc.new {|name| name.gsub(/@.*$/,'')}
1212
# :bind_dn => 'default_bind_dn'
1313
# :password => 'password'
14-
class MyLdapProvider < OmniAuth::Strategies::LDAP; end
14+
before do
15+
ldap_strategy = Class.new(OmniAuth::Strategies::LDAP)
16+
stub_const("MyLdapProvider", ldap_strategy)
17+
end
1518

1619
let(:app) do
1720
Rack::Builder.new {
@@ -181,6 +184,19 @@ def make_env(path = "/auth/ldap", props = {})
181184
expect(last_response.headers["Location"]).to match("invalid_credentials")
182185
expect(last_request.env["omniauth.error"].message).to eq("Invalid credentials for ping")
183186
end
187+
188+
it "supports group restriction filters and applies name_proc" do
189+
# Complex filter with %{username} placeholder and group membership
190+
group_filter = "(&(uid=%{username})(memberOf=cn=forum-users,ou=groups,dc=example,dc=com))"
191+
allow(@adaptor).to receive(:filter).and_return(group_filter)
192+
# username has a domain part; name_proc on strategy under test strips it
193+
expect(Net::LDAP::Filter).to receive(:construct).with("(&(uid=alice)(memberOf=cn=forum-users,ou=groups,dc=example,dc=com))")
194+
195+
post("/auth/ldap/callback", {username: "[email protected]", password: "password"})
196+
197+
expect(last_response).to be_redirect
198+
expect(last_response.headers["Location"]).to match("invalid_credentials")
199+
end
184200
end
185201
end
186202

@@ -240,6 +256,29 @@ def make_env(path = "/auth/ldap", props = {})
240256

241257
expect(last_response).not_to be_redirect
242258
end
259+
260+
it "escapes special characters in username when building filter" do
261+
allow(@adaptor).to receive(:filter).and_return("uid=%{username}")
262+
# '(' => \28 and ')' => \29 per RFC 4515 escaping
263+
expect(Net::LDAP::Filter).to receive(:construct).with("uid=al\\28ice\\29")
264+
post("/auth/ldap/callback", {username: "al(ice)", password: "secret"})
265+
end
266+
267+
it "binds with complex group filter and applies name_proc" do
268+
allow(@adaptor).to receive(:bind_as) {
269+
Net::LDAP::Entry.from_single_ldif_string(
270+
%{dn: cn=alice, dc=example, dc=com
271+
uid: alice
272+
},
273+
)
274+
}
275+
allow(@adaptor).to receive(:filter).and_return("(&(uid=%{username})(memberOf=cn=forum-users,ou=groups,dc=example,dc=com))")
276+
expect(Net::LDAP::Filter).to receive(:construct).with("(&(uid=alice)(memberOf=cn=forum-users,ou=groups,dc=example,dc=com))")
277+
278+
post("/auth/ldap/callback", {username: "[email protected]", password: "secret"})
279+
expect(last_response).not_to be_redirect
280+
expect(last_request.env["omniauth.auth"].info.nickname).to eq "alice"
281+
end
243282
end
244283

245284
it "maps user info to Auth Hash" do
@@ -440,11 +479,41 @@ def connection_returning(entry)
440479
expect(last_response).not_to be_redirect
441480
end
442481

482+
it "escapes special characters in header SSO username when building filter" do
483+
entry = Net::LDAP::Entry.from_single_ldif_string(%{dn: cn=al\\28ice\\29, dc=example, dc=com
484+
uid: al(ice)
485+
})
486+
allow(@adaptor).to receive_messages(
487+
connection: connection_returning(entry),
488+
filter: "uid=%{username}",
489+
)
490+
expect(Net::LDAP::Filter).to receive(:construct).with("uid=al\\28ice\\29").and_call_original
491+
492+
post "/auth/ldap/callback", nil, {"REMOTE_USER" => "al(ice)"}
493+
expect(last_response).not_to be_redirect
494+
end
495+
443496
it "fails when directory lookup returns no entry" do
444497
allow(@adaptor).to receive(:connection).and_return(connection_returning(nil))
445498
post "/auth/ldap/callback", nil, {"REMOTE_USER" => "missing"}
446499
expect(last_response).to be_redirect
447500
expect(last_response.headers["Location"]).to match(/invalid_credentials/)
448501
end
502+
503+
it "supports complex group filter with %{username} in header SSO path" do
504+
# Expect that the complex filter string is constructed with the processed username
505+
expect(Net::LDAP::Filter).to receive(:construct).with("(&(uid=alice)(memberOf=cn=forum-users,ou=groups,dc=example,dc=com))").and_call_original
506+
507+
entry = Net::LDAP::Entry.from_single_ldif_string(%{dn: cn=alice, dc=example, dc=com
508+
uid: alice
509+
})
510+
allow(@adaptor).to receive_messages(
511+
filter: "(&(uid=%{username})(memberOf=cn=forum-users,ou=groups,dc=example,dc=com))",
512+
connection: connection_returning(entry),
513+
)
514+
515+
post "/auth/ldap/callback", nil, {"REMOTE_USER" => "[email protected]"}
516+
expect(last_response).not_to be_redirect
517+
end
449518
end
450519
end

0 commit comments

Comments
 (0)