Skip to content

Commit b637223

Browse files
committed
✨ Forward LDAP based SSO identity via an HTTP header
- e.g., REMOTE_USER
1 parent 5d67e0d commit b637223

File tree

5 files changed

+219
-5
lines changed

5 files changed

+219
-5
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ Please file a bug if you notice a violation of semantic versioning.
2727
- Improved code coverage to 98% lines and 78% branches
2828
- Added integration tests with a complete Roda-based demo app for specs
2929
- Well tested support for all versions of OmniAuth >= v1 and Rack >= v1 via appraisals
30+
- Documentation for why auth.uid == dn
31+
- Support for LDAP-based SSO identity via HTTP Header
3032

3133
### Changed
3234

README.md

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -200,8 +200,8 @@ The following options are available for configuring the OmniAuth LDAP strategy:
200200

201201
Why DN for `auth.uid`?
202202

203-
- DN is the canonical, globally unique identifier for an LDAP entry and is always present in search results. See LDAPv3 and DN syntax: RFC 4511 (LDAP protocol) and RFC 4514 (String Representation of Distinguished Names).
204-
- Attributes like `uid` (defined in RFC 4519) or `sAMAccountName` (Active Directory–specific) may be absent, duplicated across parts of the DIT, or vary between directories. Using DN ensures consistent behavior across AD, OpenLDAP, and other servers.
203+
- DN is the canonical, globally unique identifier for an LDAP entry and is always present in search results. See LDAPv3 and DN syntax: [RFC 4511][rfc4511] (LDAP protocol) and [RFC 4514][rfc4514] (String Representation of Distinguished Names).
204+
- Attributes like `uid` (defined in [RFC 4519][rfc4519]) or `sAMAccountName` (Active Directory–specific) may be absent, duplicated across parts of the DIT, or vary between directories. Using DN ensures consistent behavior across AD, OpenLDAP, and other servers.
205205
- This trade-off favors cross-directory interoperability and stability for apps that need a unique identifier.
206206

207207
Where to find the "username"-style value
@@ -341,6 +341,67 @@ provider :ldap,
341341

342342
This trims `[email protected]` to `alice` before searching.
343343

344+
### Trusted header SSO (REMOTE_USER and friends)
345+
346+
Some deployments terminate SSO at a reverse proxy or portal and forward the already-authenticated user identity via an HTTP header such as `REMOTE_USER`.
347+
When you enable this mode, the LDAP strategy will trust the upstream header, perform a directory lookup for that user, and complete OmniAuth without asking the user for a password.
348+
349+
Important: Only enable this behind a trusted front-end that strips and sets the header itself. Never enable on a public endpoint without such a gateway, or an attacker could spoof the header.
350+
351+
Configuration options:
352+
353+
- `:header_auth` (Boolean, default: false) — Enable trusted header SSO.
354+
- `:header_name` (String, default: "REMOTE_USER") — The env/header key to read. The strategy checks both `env["REMOTE_USER"]` and the Rack variant `env["HTTP_REMOTE_USER"]`.
355+
- `:name_proc` is applied to the header value before search (e.g., to strip a domain part).
356+
- Search is done using your configured `:uid` or `:filter` and the service bind (`:bind_dn`/`:password`) or anonymous bind if allowed.
357+
358+
Minimal Rack example:
359+
360+
```ruby
361+
use OmniAuth::Builder do
362+
provider :ldap,
363+
host: "ldap.example.com",
364+
base: "dc=example,dc=com",
365+
uid: "uid",
366+
bind_dn: "cn=search,dc=example,dc=com",
367+
password: ENV["LDAP_SEARCH_PASSWORD"],
368+
header_auth: true, # trust REMOTE_USER
369+
header_name: "REMOTE_USER", # default
370+
name_proc: proc { |n| n.split("@").first }
371+
end
372+
```
373+
374+
Rails initializer example:
375+
376+
```ruby
377+
Rails.application.config.middleware.use(OmniAuth::Builder) do
378+
provider :ldap,
379+
title: "Acme LDAP",
380+
host: "ldap.acme.internal",
381+
base: "dc=acme,dc=corp",
382+
uid: "sAMAccountName",
383+
bind_dn: "cn=search,dc=acme,dc=corp",
384+
password: ENV["LDAP_SEARCH_PASSWORD"],
385+
header_auth: true,
386+
header_name: "REMOTE_USER",
387+
# Optionally restrict with a group filter while using the header value
388+
filter: "(&(sAMAccountName=%{username})(memberOf=cn=myapp-users,ou=groups,dc=acme,dc=corp))",
389+
name_proc: proc { |n| n.gsub(/@.*$/, "") }
390+
end
391+
```
392+
393+
Flow:
394+
395+
- If `header_auth` is on and the header is present when the request hits `/auth/ldap`, the strategy immediately redirects to `/auth/ldap/callback`.
396+
- In the callback, the strategy searches the directory for that user and maps their attributes; no user password bind is attempted.
397+
- If the header is missing (or `header_auth` is false), the normal username/password form flow is used.
398+
399+
Security checklist:
400+
401+
- Ensure your reverse proxy strips user-controlled copies of the header and sets the canonical `REMOTE_USER` itself.
402+
- Prefer TLS-secured internal links between the proxy and your app.
403+
- Consider also restricting with a group-based `:filter` so only authorized users can sign in.
404+
344405
## 🦷 FLOSS Funding
345406

346407
While these tools are free software and will always be, the project would benefit immensely from some funding.

lib/omniauth/strategies/ldap.rb

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,13 @@ class LDAP
3939
option :ssl_version, nil # use OpenSSL default if nil
4040
option :uid, "sAMAccountName"
4141
option :name_proc, lambda { |n| n }
42+
# Trusted header SSO support (disabled by default)
43+
# :header_auth - when true and the header is present, the strategy trusts the upstream gateway
44+
# and searches the directory for the user without requiring a user password.
45+
# :header_name - which header/env key to read (default: "REMOTE_USER"). We will also check the
46+
# standard Rack "HTTP_" variant automatically.
47+
option :header_auth, false
48+
option :header_name, "REMOTE_USER"
4249

4350
def request_phase
4451
# OmniAuth >= 2.0 expects the request phase to be POST-only for /auth/:provider.
@@ -47,6 +54,12 @@ def request_phase
4754
return Rack::Response.new("", 404, {"Content-Type" => "text/plain"}).finish
4855
end
4956

57+
# Fast-path: if a trusted identity header is present, skip the login form
58+
# and jump to the callback where we will complete using directory lookup.
59+
if header_username
60+
return Rack::Response.new([], 302, "Location" => callback_path).finish
61+
end
62+
5063
# If credentials were POSTed directly to /auth/:provider, redirect to the callback path.
5164
# This mirrors the behavior of many OmniAuth providers and allows test helpers (like
5265
# OmniAuth::Test::PhonySession) to populate `env['omniauth.auth']` on the callback request.
@@ -66,6 +79,22 @@ def callback_phase
6679
@adaptor = OmniAuth::LDAP::Adaptor.new(@options)
6780

6881
return fail!(:invalid_request_method) unless valid_request_method?
82+
83+
# Header-based SSO (REMOTE_USER-style) path
84+
if (hu = header_username)
85+
begin
86+
entry = directory_lookup(@adaptor, hu)
87+
unless entry
88+
return fail!(:invalid_credentials, InvalidCredentialsError.new("User not found for header #{hu}"))
89+
end
90+
@ldap_user_info = entry
91+
@user_info = self.class.map_user(CONFIG, @ldap_user_info)
92+
return super
93+
rescue => e
94+
return fail!(:ldap_error, e)
95+
end
96+
end
97+
6998
return fail!(:missing_credentials) if missing_credentials?
7099
begin
71100
@ldap_user_info = @adaptor.bind_as(filter: filter(@adaptor), size: 1, password: request.params["password"])
@@ -81,12 +110,12 @@ def callback_phase
81110
end
82111
end
83112

84-
def filter(adaptor)
113+
def filter(adaptor, username_override = nil)
85114
if adaptor.filter && !adaptor.filter.empty?
86-
username = Net::LDAP::Filter.escape(@options[:name_proc].call(request.params["username"]))
115+
username = Net::LDAP::Filter.escape(@options[:name_proc].call(username_override || request.params["username"]))
87116
Net::LDAP::Filter.construct(adaptor.filter % {username: username})
88117
else
89-
Net::LDAP::Filter.equals(adaptor.uid, @options[:name_proc].call(request.params["username"]))
118+
Net::LDAP::Filter.equals(adaptor.uid, @options[:name_proc].call(username_override || request.params["username"]))
90119
end
91120
end
92121

@@ -146,6 +175,31 @@ def valid_request_method?
146175
def missing_credentials?
147176
request.params["username"].nil? || request.params["username"].empty? || request.params["password"].nil? || request.params["password"].empty?
148177
end # missing_credentials?
178+
179+
# Extract a normalized username from a trusted header when enabled.
180+
# Returns nil when not configured or not present.
181+
def header_username
182+
return unless options[:header_auth]
183+
184+
name = options[:header_name] || "REMOTE_USER"
185+
# Try both the raw env var (e.g., REMOTE_USER) and the Rack HTTP_ variant (e.g., HTTP_REMOTE_USER or HTTP_X_REMOTE_USER)
186+
raw = request.env[name] || request.env["HTTP_#{name.upcase.tr("-", "_")}"]
187+
return if raw.nil? || raw.to_s.strip.empty?
188+
189+
options[:name_proc].call(raw.to_s)
190+
end
191+
192+
# Perform a directory lookup for the given username using the strategy configuration
193+
# (bind_dn/password or anonymous). Does not attempt to bind as the user.
194+
def directory_lookup(adaptor, username)
195+
entry = nil
196+
filter = filter(adaptor, username)
197+
adaptor.connection.open do |conn|
198+
rs = conn.search(filter: filter, size: 1)
199+
entry = rs.first if rs && rs.first
200+
end
201+
entry
202+
end
149203
end
150204
end
151205
end

sig/omniauth/strategies/ldap.rbs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,20 @@ module OmniAuth
1313
def callback_phase: () -> untyped
1414

1515
# Accepts an adaptor and returns a Net::LDAP::Filter or similar
16+
# Optional second argument allows overriding the username (used for header-based SSO)
1617
def filter: (OmniAuth::LDAP::Adaptor) -> Net::LDAP::Filter
18+
| (OmniAuth::LDAP::Adaptor, String?) -> Net::LDAP::Filter
1719

1820
# Map a user object (Net::LDAP::Entry-like) into a Hash for the auth info
1921
def self.map_user: (Hash[String, untyped], untyped) -> Hash[String, untyped]
2022

2123
def missing_credentials?: () -> bool
24+
25+
# Extract username from a trusted header when enabled
26+
def header_username: () -> (String | nil)
27+
28+
# Perform a directory lookup for a given username; returns an Entry or nil
29+
def directory_lookup: (OmniAuth::LDAP::Adaptor, String) -> untyped
2230
end
2331
end
2432
end

spec/omniauth/strategies/ldap_spec.rb

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,4 +357,93 @@ def make_env(path = "/auth/ldap", props = {})
357357
expect(auth.info.nickname).to eq "ping" # comes from sAMAccountName
358358
end
359359
end
360+
361+
# Header-based SSO (REMOTE_USER) support
362+
describe "trusted header SSO" do
363+
let(:app) do
364+
Rack::Builder.new do
365+
use OmniAuth::Test::PhonySession
366+
use MyHeaderProvider,
367+
name: "ldap",
368+
title: "Header LDAP",
369+
host: "ldap.example.com",
370+
base: "dc=example,dc=com",
371+
uid: "uid",
372+
header_auth: true,
373+
header_name: "REMOTE_USER",
374+
name_proc: proc { |n| n.gsub(/@.*$/, "") }
375+
run lambda { |env| [404, {"Content-Type" => "text/plain"}, [env.key?("omniauth.auth").to_s]] }
376+
end.to_app
377+
end
378+
379+
before do
380+
ldap_strategy = Class.new(OmniAuth::Strategies::LDAP)
381+
stub_const("MyHeaderProvider", ldap_strategy)
382+
@adaptor = double(OmniAuth::LDAP::Adaptor, {uid: "uid", filter: nil})
383+
allow(OmniAuth::LDAP::Adaptor).to receive(:new) { @adaptor }
384+
end
385+
386+
def connection_returning(entry)
387+
searcher = double("ldap search conn")
388+
allow(searcher).to receive(:search).and_return(entry ? [entry] : [])
389+
conn = double("ldap connection")
390+
allow(conn).to receive(:open).and_yield(searcher)
391+
conn
392+
end
393+
394+
it "redirects from request phase when header present" do
395+
env = {"rack.session" => {}, "REQUEST_METHOD" => "POST", "PATH_INFO" => "/auth/ldap", "REMOTE_USER" => "alice"}
396+
post "/auth/ldap", nil, env
397+
expect(last_response).to be_redirect
398+
expect(last_response.headers["Location"]).to eq "/auth/ldap/callback"
399+
end
400+
401+
it "authenticates on callback without password using REMOTE_USER" do
402+
entry = Net::LDAP::Entry.from_single_ldif_string(%{dn: cn=alice, dc=example, dc=com
403+
uid: alice
404+
405+
})
406+
allow(@adaptor).to receive(:connection).and_return(connection_returning(entry))
407+
408+
post "/auth/ldap/callback", nil, {"REMOTE_USER" => "alice"}
409+
410+
expect(last_response).not_to be_redirect
411+
auth = last_request.env["omniauth.auth"]
412+
expect(auth.uid).to eq "cn=alice, dc=example, dc=com"
413+
expect(auth.info.nickname).to eq "alice"
414+
end
415+
416+
it "authenticates on callback with HTTP_ header variant" do
417+
entry = Net::LDAP::Entry.from_single_ldif_string(%{dn: cn=alice, dc=example, dc=com
418+
uid: alice
419+
})
420+
allow(@adaptor).to receive(:connection).and_return(connection_returning(entry))
421+
422+
post "/auth/ldap/callback", nil, {"HTTP_REMOTE_USER" => "alice"}
423+
expect(last_response).not_to be_redirect
424+
auth = last_request.env["omniauth.auth"]
425+
expect(auth.info.nickname).to eq "alice"
426+
end
427+
428+
it "applies name_proc and filter mapping when provided" do
429+
allow(@adaptor).to receive(:filter).and_return("uid=%{username}")
430+
expect(Net::LDAP::Filter).to receive(:construct).with("uid=alice").and_call_original
431+
432+
# search result
433+
entry = Net::LDAP::Entry.from_single_ldif_string(%{dn: cn=alice, dc=example, dc=com
434+
uid: alice
435+
})
436+
allow(@adaptor).to receive(:connection).and_return(connection_returning(entry))
437+
438+
post "/auth/ldap/callback", nil, {"REMOTE_USER" => "[email protected]"}
439+
expect(last_response).not_to be_redirect
440+
end
441+
442+
it "fails when directory lookup returns no entry" do
443+
allow(@adaptor).to receive(:connection).and_return(connection_returning(nil))
444+
post "/auth/ldap/callback", nil, {"REMOTE_USER" => "missing"}
445+
expect(last_response).to be_redirect
446+
expect(last_response.headers["Location"]).to match(/invalid_credentials/)
447+
end
448+
end
360449
end

0 commit comments

Comments
 (0)