-
-
Notifications
You must be signed in to change notification settings - Fork 158
✨ Forward LDAP based SSO identity via an HTTP header #102
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -200,8 +200,8 @@ The following options are available for configuring the OmniAuth LDAP strategy: | |
|
|
||
| Why DN for `auth.uid`? | ||
|
|
||
| - 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). | ||
| - 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. | ||
| - 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). | ||
| - 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. | ||
| - This trade-off favors cross-directory interoperability and stability for apps that need a unique identifier. | ||
|
|
||
| Where to find the "username"-style value | ||
|
|
@@ -341,6 +341,67 @@ provider :ldap, | |
|
|
||
| This trims `[email protected]` to `alice` before searching. | ||
|
|
||
| ### Trusted header SSO (REMOTE_USER and friends) | ||
|
|
||
| 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`. | ||
| 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. | ||
|
|
||
| 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. | ||
|
|
||
| Configuration options: | ||
|
|
||
| - `:header_auth` (Boolean, default: false) — Enable trusted header SSO. | ||
| - `: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"]`. | ||
| - `:name_proc` is applied to the header value before search (e.g., to strip a domain part). | ||
| - Search is done using your configured `:uid` or `:filter` and the service bind (`:bind_dn`/`:password`) or anonymous bind if allowed. | ||
|
|
||
| Minimal Rack example: | ||
|
|
||
| ```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"], | ||
| header_auth: true, # trust REMOTE_USER | ||
| header_name: "REMOTE_USER", # default | ||
| name_proc: proc { |n| n.split("@").first } | ||
| end | ||
| ``` | ||
|
|
||
| Rails initializer example: | ||
|
|
||
| ```ruby | ||
| Rails.application.config.middleware.use(OmniAuth::Builder) do | ||
| provider :ldap, | ||
| title: "Acme LDAP", | ||
| host: "ldap.acme.internal", | ||
| base: "dc=acme,dc=corp", | ||
| uid: "sAMAccountName", | ||
| bind_dn: "cn=search,dc=acme,dc=corp", | ||
| password: ENV["LDAP_SEARCH_PASSWORD"], | ||
| header_auth: true, | ||
| header_name: "REMOTE_USER", | ||
| # Optionally restrict with a group filter while using the header value | ||
| filter: "(&(sAMAccountName=%{username})(memberOf=cn=myapp-users,ou=groups,dc=acme,dc=corp))", | ||
| name_proc: proc { |n| n.gsub(/@.*$/, "") } | ||
| end | ||
| ``` | ||
|
|
||
| Flow: | ||
|
|
||
| - If `header_auth` is on and the header is present when the request hits `/auth/ldap`, the strategy immediately redirects to `/auth/ldap/callback`. | ||
| - In the callback, the strategy searches the directory for that user and maps their attributes; no user password bind is attempted. | ||
| - If the header is missing (or `header_auth` is false), the normal username/password form flow is used. | ||
|
|
||
| Security checklist: | ||
|
|
||
| - Ensure your reverse proxy strips user-controlled copies of the header and sets the canonical `REMOTE_USER` itself. | ||
| - Prefer TLS-secured internal links between the proxy and your app. | ||
| - Consider also restricting with a group-based `:filter` so only authorized users can sign in. | ||
|
|
||
| ## 🦷 FLOSS Funding | ||
|
|
||
| While these tools are free software and will always be, the project would benefit immensely from some funding. | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -357,4 +357,94 @@ def make_env(path = "/auth/ldap", props = {}) | |
| expect(auth.info.nickname).to eq "ping" # comes from sAMAccountName | ||
| end | ||
| end | ||
|
|
||
| # Header-based SSO (REMOTE_USER) support | ||
| describe "trusted header SSO" do | ||
| let(:app) do | ||
| Rack::Builder.new do | ||
| use OmniAuth::Test::PhonySession | ||
| use MyHeaderProvider, | ||
| name: "ldap", | ||
| title: "Header LDAP", | ||
| host: "ldap.example.com", | ||
| base: "dc=example,dc=com", | ||
| uid: "uid", | ||
| header_auth: true, | ||
| header_name: "REMOTE_USER", | ||
| name_proc: proc { |n| n.gsub(/@.*$/, "") } | ||
| run lambda { |env| [404, {"Content-Type" => "text/plain"}, [env.key?("omniauth.auth").to_s]] } | ||
| end.to_app | ||
| end | ||
|
|
||
| before do | ||
| ldap_strategy = Class.new(OmniAuth::Strategies::LDAP) | ||
| stub_const("MyHeaderProvider", ldap_strategy) | ||
| @adaptor = double(OmniAuth::LDAP::Adaptor, {uid: "uid", filter: nil}) | ||
| allow(OmniAuth::LDAP::Adaptor).to receive(:new) { @adaptor } | ||
| end | ||
|
|
||
| def connection_returning(entry) | ||
| searcher = double("ldap search conn") | ||
| allow(searcher).to receive(:search).and_return(entry ? [entry] : []) | ||
| conn = double("ldap connection") | ||
| allow(conn).to receive(:open).and_yield(searcher) | ||
| conn | ||
| end | ||
|
|
||
| it "redirects from request phase when header present" do | ||
| env = {"rack.session" => {}, "REQUEST_METHOD" => "POST", "PATH_INFO" => "/auth/ldap", "REMOTE_USER" => "alice"} | ||
| post "/auth/ldap", nil, env | ||
| expect(last_response).to be_redirect | ||
| expect(last_response.headers["Location"]).to eq "/auth/ldap/callback" | ||
| end | ||
|
|
||
| it "authenticates on callback without password using REMOTE_USER" do | ||
| entry = Net::LDAP::Entry.from_single_ldif_string(%{dn: cn=alice, dc=example, dc=com | ||
| uid: alice | ||
| mail: [email protected] | ||
| }) | ||
| allow(@adaptor).to receive(:connection).and_return(connection_returning(entry)) | ||
|
|
||
| post "/auth/ldap/callback", nil, {"REMOTE_USER" => "alice"} | ||
|
|
||
| expect(last_response).not_to be_redirect | ||
| auth = last_request.env["omniauth.auth"] | ||
| expect(auth.uid).to eq "cn=alice, dc=example, dc=com" | ||
| expect(auth.info.nickname).to eq "alice" | ||
| end | ||
|
|
||
| it "authenticates on callback with HTTP_ header variant" do | ||
| entry = Net::LDAP::Entry.from_single_ldif_string(%{dn: cn=alice, dc=example, dc=com | ||
| uid: alice | ||
| }) | ||
| allow(@adaptor).to receive(:connection).and_return(connection_returning(entry)) | ||
|
|
||
| post "/auth/ldap/callback", nil, {"HTTP_REMOTE_USER" => "alice"} | ||
| expect(last_response).not_to be_redirect | ||
| auth = last_request.env["omniauth.auth"] | ||
| expect(auth.info.nickname).to eq "alice" | ||
| end | ||
|
|
||
| it "applies name_proc and filter mapping when provided" do | ||
| # search result | ||
| entry = Net::LDAP::Entry.from_single_ldif_string(%{dn: cn=alice, dc=example, dc=com | ||
| uid: alice | ||
| }) | ||
| allow(@adaptor).to receive_messages( | ||
| filter: "uid=%{username}", | ||
| connection: connection_returning(entry), | ||
| ) | ||
| expect(Net::LDAP::Filter).to receive(:construct).with("uid=alice").and_call_original | ||
|
|
||
| post "/auth/ldap/callback", nil, {"REMOTE_USER" => "[email protected]"} | ||
| expect(last_response).not_to be_redirect | ||
| end | ||
|
|
||
| it "fails when directory lookup returns no entry" do | ||
| allow(@adaptor).to receive(:connection).and_return(connection_returning(nil)) | ||
| post "/auth/ldap/callback", nil, {"REMOTE_USER" => "missing"} | ||
| expect(last_response).to be_redirect | ||
| expect(last_response.headers["Location"]).to match(/invalid_credentials/) | ||
| end | ||
| end | ||
| end | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Bare rescue catches all exceptions including syntax errors and system exits. Change to
rescue StandardError => eto avoid catching non-application exceptions.