Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
6a6fa1d
allow automatic login via configured header value
aroberts Mar 26, 2025
1557b9c
added basic doc for the header-auth feature
aroberts Apr 5, 2025
ae33a7c
auth(remote-header): raise on JIT save failure
aroberts May 4, 2026
d562aa5
auth(remote-header): handle race on concurrent JIT user creation
aroberts May 4, 2026
2fe76f8
auth(remote-header): drop cookie session when proxy asserts a differe…
aroberts May 4, 2026
ff56081
auth(remote-header): use role_for_new_family_creator for first user
aroberts May 4, 2026
a672b93
auth(remote-header): leave password_digest nil for JIT users
aroberts May 4, 2026
dc4a4c3
auth(remote-header): emit SsoAuditLog entries for JIT and login
aroberts May 4, 2026
3ecc44b
auth(remote-header): validate header email shape before lookup
aroberts May 4, 2026
4eeab4f
auth(remote-header): add optional trusted-proxy allowlist
aroberts May 4, 2026
8c8ec17
test(remote-header): integration coverage for the auth gates
aroberts May 6, 2026
ac053ed
auth(remote-header): apply review feedback
aroberts May 6, 2026
1344550
auth(remote-header): broaden sso_only? to cover password-less users
aroberts May 7, 2026
748b0cd
auth(remote-header): self-hosted guard + startup warning when allowli…
aroberts May 7, 2026
afc3199
test(remote-header): assert flash content + dedupe JIT user setup
aroberts May 7, 2026
3778578
auth(remote-header): default trusted-proxies to loopback (fail-closed)
aroberts May 7, 2026
e8d008b
auth(remote-header): optional shared-secret header gate
aroberts May 8, 2026
c4f99e0
Pipelock noise
jjmata May 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/pipelock.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,10 @@ jobs:
test-vectors: 'false'
exclude-paths: |
.env.example
app/models/provider/binance.rb
compose.example.yml
compose.example.ai.yml
config/locales/views/reports/
docs/hosting/ai.md
app/models/provider/binance.rb
docs/hosting/docker.md
test/
93 changes: 92 additions & 1 deletion app/controllers/concerns/authentication.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
module Authentication
extend ActiveSupport::Concern

REMOTE_HEADER_SSO_PROVIDER = "remote_user_header"

included do
before_action :set_request_details
before_action :authenticate_user!
Expand All @@ -16,7 +18,17 @@ def skip_authentication(**options)

private
def authenticate_user!
if session_record = find_session_by_cookie
cookie_session = find_session_by_cookie

if cookie_session && cookie_session_disagrees_with_header?(cookie_session)
cookie_session.destroy
cookies.delete(:session_token)
cookie_session = nil
end

if cookie_session
Current.session = cookie_session
elsif session_record = create_session_by_remote_header
Current.session = session_record
else
if self_hosted_first_login?
Expand All @@ -27,6 +39,85 @@ def authenticate_user!
end
end

def cookie_session_disagrees_with_header?(session)
email = trusted_remote_user_email
email.present? && session.user.email != email
end

def create_session_by_remote_header
return unless user_email = trusted_remote_user_email

user, created = find_or_create_remote_header_user(user_email)
if created
SsoAuditLog.log_jit_account_created!(
user: user,
provider: REMOTE_HEADER_SSO_PROVIDER,
request: request
)
end
SsoAuditLog.log_login!(
user: user,
provider: REMOTE_HEADER_SSO_PROVIDER,
request: request
)
create_session_for(user)
end

# Returns the email asserted by the upstream proxy, but only when the
# request passes all configured trust gates: self-hosted mode, header
# set, source IP in the trusted-proxies allowlist, shared-secret match
# (if configured), and email shape is valid.
def trusted_remote_user_email
return nil unless Rails.application.config.app_mode.self_hosted?

header_name = Rails.application.config.remote_user_header_email
return nil if header_name.blank?
return nil unless remote_user_proxy_trusted?
return nil unless remote_user_secret_valid?

email = request.headers[header_name]&.strip&.downcase
return nil if email.blank?
return nil unless URI::MailTo::EMAIL_REGEXP.match?(email)

email
end

def remote_user_proxy_trusted?
trusted = Rails.application.config.remote_user_trusted_proxies
peer_ip = IPAddr.new(request.env["REMOTE_ADDR"])
trusted.any? { |range| range.include?(peer_ip) }
rescue IPAddr::Error
false
Comment thread
coderabbitai[bot] marked this conversation as resolved.
end
Comment thread
coderabbitai[bot] marked this conversation as resolved.

def remote_user_secret_valid?
expected = Rails.application.config.remote_user_shared_secret
return true if expected.blank?

provided = request.headers[Rails.application.config.remote_user_shared_secret_header].to_s
ActiveSupport::SecurityUtils.secure_compare(expected, provided)
end

def find_or_create_remote_header_user(user_email)
if user = User.find_by(email: user_email)
[ user, false ]
else
# Leave password_digest nil so the user can't fall back to local
# password login or password reset; the proxy is the only path in.
user = User.new
user.email = user_email
user.skip_password_validation = true
user.family = Family.new
Comment on lines +108 to +110
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Persist remote-header users as SSO-only accounts

This JIT path creates users with password_digest unset, but it does not attach any marker that makes them sso_only?; User#sso_only? currently requires an OIDC identity, so these accounts are treated as resettable local accounts in PasswordResetsController. In practice, a header-provisioned user can request a password reset email and set a local password, which breaks the intended "proxy-only" authentication model for this feature.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Independently confirmed against head 40286d8. User#sso_only? requires oidc_identities.exists?, which is false for JIT remote-header users (they have no OIDC identity attached). Both the email-send gate (unless user.sso_only? in create) and the write gate (if @user.sso_only? in update) evaluate to the wrong branch for these users — password reset emails are sent and the password update is allowed through. The PR description's claim that these users "can't fall back to local login or password reset" is not implemented. Needs a fix before merge — see the review comment above for specifics.


Generated by Claude Code

user.role = User.role_for_new_family_creator(fallback_role: :admin)
begin
user.save!
[ user, true ]
rescue ActiveRecord::RecordNotUnique
[ User.find_by!(email: user_email), false ]
end
end
end

def find_session_by_cookie
cookie_value = cookies.signed[:session_token]

Expand Down
8 changes: 5 additions & 3 deletions app/models/user.rb
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Asked about this in our Discord here.

Original file line number Diff line number Diff line change
Expand Up @@ -169,10 +169,12 @@ def self.default_ui_layout
layout.in?(%w[intro dashboard]) ? layout : "dashboard"
end

# SSO-only users have OIDC identities but no local password.
# They cannot use password reset or local login.
# Users without a local password — provisioned via OIDC SSO or via a
# trusted upstream proxy header. They cannot use password reset or
# local login; the only path back in is through the same external
# auth that provisioned them.
def sso_only?
password_digest.nil? && oidc_identities.exists?
password_digest.nil?
end

# Check if user has a local password set (can authenticate locally)
Expand Down
15 changes: 15 additions & 0 deletions config/application.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,21 @@ class Application < Rails::Application

config.app_mode = (ENV["SELF_HOSTED"] == "true" || ENV["SELF_HOSTING_ENABLED"] == "true" ? "self_hosted" : "managed").inquiry

config.remote_user_header_email = ENV["REMOTE_USER_HEADER_EMAIL"]
# Default to loopback only so a misconfigured deployment fails closed
# at first login attempt rather than silently honoring the header from
# any source. Set REMOTE_USER_TRUSTED_PROXIES to widen the allowlist.
config.remote_user_trusted_proxies = (ENV["REMOTE_USER_TRUSTED_PROXIES"].presence || "127.0.0.0/8,::1/128")
.split(",")
.map(&:strip)
.reject(&:empty?)
.filter_map { |s| IPAddr.new(s) rescue nil }
# Optional shared-secret gate: when REMOTE_USER_SHARED_SECRET is set,
# the proxy must echo it in the configured sibling header. Unset means
# no shared-secret check (the IP allowlist remains the only gate).
config.remote_user_shared_secret = ENV["REMOTE_USER_SHARED_SECRET"].presence
config.remote_user_shared_secret_header = ENV.fetch("REMOTE_USER_SHARED_SECRET_HEADER", "X-Remote-User-Secret")

# Self hosters can optionally set their own encryption keys if they want to use ActiveRecord encryption.
if Rails.application.credentials.active_record_encryption.present?
config.active_record.encryption = Rails.application.credentials.active_record_encryption
Expand Down
30 changes: 30 additions & 0 deletions docs/hosting/docker.md
Original file line number Diff line number Diff line change
Expand Up @@ -316,3 +316,33 @@ docker compose exec db psql -U sure_user -d sure_development -c "SELECT 1;" # Th
### Slow `.csv` import (processing rows taking longer than expected)

Importing comma-separated-value file(s) requires the `sure-worker` container to communicate with Redis. Check your worker logs for any unexpected errors, such as connection timeouts or Redis communication failures.

## Reverse-proxy authentication

Sure can be configured to trust a request header set by an upstream reverse proxy and use it to log a user in automatically (passwordless). This is intended to be used in conjunction with separate authorization software running in front of Sure. Self-hosted only — managed deployments ignore the configuration.

For more information and examples, see https://doc.traefik.io/traefik/middlewares/http/forwardauth/ or similar documentation for your HTTP proxy and authentication software.

Configure the Sure environment with the name of the header that carries the authenticated user's email:
```txt
REMOTE_USER_HEADER_EMAIL="Remote-Email"
```

!! NOTE!! this allows unchallenged (passwordless) login via simple HTTP headers. Only use this method if you have a proxy in front of Sure that is applying the authentication challenge, *AND THE SURE HTTP SERVER IS NOT ACCESSIBLE DIRECTLY*.

### Source-IP allowlist

Sure honors the header only when the immediate peer (`REMOTE_ADDR`) is in this list. The default is loopback (`127.0.0.0/8,::1/128`) — fail-closed by design, so a misconfigured deployment surfaces a broken login at first test rather than silently default-opening. If your proxy lives on a different host or container, override it:
```txt
REMOTE_USER_TRUSTED_PROXIES="10.0.0.5,172.18.0.0/16"
```
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Setting the variable replaces the default. A set-but-empty or unparseable value resolves to an empty allowlist — every request is treated as outside it and the header is ignored.

### Shared-secret header (optional)

Useful when the IP allowlist alone isn't sufficient — e.g. multi-tenant Docker bridge where many containers share an IP range, or a topology where the immediate peer isn't fully trusted. When `REMOTE_USER_SHARED_SECRET` is set, the proxy must echo the same value in a sibling header on every request; mismatches and missing headers are rejected with a constant-time compare:
```txt
REMOTE_USER_SHARED_SECRET="long-random-string-from-openssl-rand-hex-32"
REMOTE_USER_SHARED_SECRET_HEADER="X-Remote-User-Secret"
```
`REMOTE_USER_SHARED_SECRET_HEADER` defaults to `X-Remote-User-Secret` and only needs setting if your proxy can't use that name. Leave `REMOTE_USER_SHARED_SECRET` unset to disable the check (the IP allowlist remains the only gate).
38 changes: 38 additions & 0 deletions test/controllers/password_resets_controller_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -87,4 +87,42 @@ class PasswordResetsControllerTest < ActionDispatch::IntegrationTest
sso_user.reload
assert_nil sso_user.password_digest, "SSO-only user should still have nil password_digest"
end

# Security: users provisioned with no local password (e.g. via the
# remote-header proxy auth path) must not be able to acquire a
# password through reset, even when they have no OIDC identity.
test "create does not send email for password-less user without OIDC identity" do
jit_user = create_jit_user

assert_no_enqueued_emails do
post password_reset_path, params: { email: jit_user.email }
end

assert_redirected_to new_password_reset_url(step: "pending")
end

test "update blocks password setting for password-less user without OIDC identity" do
jit_user = create_jit_user
token = jit_user.generate_token_for(:password_reset)

patch password_reset_path(token: token),
params: { user: { password: "NewSecure1!", password_confirmation: "NewSecure1!" } }

assert_redirected_to new_session_path
assert_match(/SSO/, flash[:alert])
assert_match(/authentication/, flash[:alert])
assert_match(/contact your administrator/, flash[:alert])
jit_user.reload
assert_nil jit_user.password_digest, "password-less user should still have nil password_digest after update attempt"
end
Comment thread
coderabbitai[bot] marked this conversation as resolved.

private
def create_jit_user(email: "[email protected]")
User.create!(
email: email,
family: Family.create!,
role: :admin,
skip_password_validation: true
)
end
end
Loading
Loading