Skip to content

Commit 1170a30

Browse files
authored
Merge pull request #102 from omniauth/feat/forward-sso-identity-from-http-header
2 parents 5d67e0d + 119c7c3 commit 1170a30

File tree

8 files changed

+224
-9
lines changed

8 files changed

+224
-9
lines changed

.envrc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export K_SOUP_COV_COMMAND_NAME="Test Coverage"
2222
# Available formats are html, xml, rcov, lcov, json, tty
2323
export K_SOUP_COV_FORMATTERS="html,xml,rcov,lcov,json,tty"
2424
export K_SOUP_COV_MIN_BRANCH=78 # Means you want to enforce X% branch coverage
25-
export K_SOUP_COV_MIN_LINE=98 # Means you want to enforce X% line coverage
25+
export K_SOUP_COV_MIN_LINE=97 # Means you want to enforce X% line coverage
2626
export K_SOUP_COV_MIN_HARD=true # Means you want the build to fail if the coverage thresholds are not met
2727
export K_SOUP_COV_MULTI_FORMATTERS=true
2828
export K_SOUP_COV_OPEN_BIN= # Means don't try to open coverage results in browser

.github/workflows/coverage.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ permissions:
77

88
env:
99
K_SOUP_COV_MIN_BRANCH: 78
10-
K_SOUP_COV_MIN_LINE: 98
10+
K_SOUP_COV_MIN_LINE: 97
1111
K_SOUP_COV_MIN_HARD: true
1212
K_SOUP_COV_FORMATTERS: "xml,rcov,lcov,tty"
1313
K_SOUP_COV_DO: true
@@ -115,7 +115,7 @@ jobs:
115115
hide_complexity: true
116116
indicators: true
117117
output: both
118-
thresholds: '98 78'
118+
thresholds: '97 78'
119119
continue-on-error: ${{ matrix.experimental != 'false' }}
120120

121121
- name: Add Coverage PR Comment

.rubocop_gradual.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
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:1003951887": [
33+
"spec/omniauth/strategies/ldap_spec.rb:1788355205": [
3434
[14, 3, 54, "RSpec/LeakyConstantDeclaration: Stub class constant instead of declaring explicitly.", 2419068710],
3535
[90, 13, 9, "RSpec/ContextWording: Context description should match /^when\\b/, /^with\\b/, or /^without\\b/.", 1130140517],
3636
[145, 17, 28, "RSpec/ContextWording: Context description should match /^when\\b/, /^with\\b/, or /^without\\b/.", 3444838747],

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: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,4 +357,94 @@ 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+
# search result
430+
entry = Net::LDAP::Entry.from_single_ldif_string(%{dn: cn=alice, dc=example, dc=com
431+
uid: alice
432+
})
433+
allow(@adaptor).to receive_messages(
434+
filter: "uid=%{username}",
435+
connection: connection_returning(entry),
436+
)
437+
expect(Net::LDAP::Filter).to receive(:construct).with("uid=alice").and_call_original
438+
439+
post "/auth/ldap/callback", nil, {"REMOTE_USER" => "[email protected]"}
440+
expect(last_response).not_to be_redirect
441+
end
442+
443+
it "fails when directory lookup returns no entry" do
444+
allow(@adaptor).to receive(:connection).and_return(connection_returning(nil))
445+
post "/auth/ldap/callback", nil, {"REMOTE_USER" => "missing"}
446+
expect(last_response).to be_redirect
447+
expect(last_response.headers["Location"]).to match(/invalid_credentials/)
448+
end
449+
end
360450
end

0 commit comments

Comments
 (0)