Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
21 changes: 21 additions & 0 deletions .env_sample
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,18 @@ NODE_ENV="development"
######### Optional 3rd Party Services ##########
################################################

# Optional delegated API authentication
# DELEGATED_ACCESS_ENABLED=true
# DELEGATED_ACCESS_ISSUER="https://issuer.example.com"
# DELEGATED_ACCESS_AUDIENCE="https://forem.example.com"
# DELEGATED_ACCESS_IDENTITY_PROVIDER="external-provider"
# DELEGATED_ACCESS_OWNER_CLAIM="https://issuer.example.com/claims/dev_user_id"
# DELEGATED_ACCESS_JWKS_URI="https://issuer.example.com/.well-known/jwks.json"
# JWKS are cached this long; unknown key IDs never force a refetch, so the
# issuer must publish a new signing key at least this long before using it.
# DELEGATED_ACCESS_JWKS_MAX_AGE_SECONDS=300
# DELEGATED_ACCESS_MAX_TOKEN_LIFETIME_SECONDS=60

# Honeybadger for error tracking
# (https://docs.honeybadger.io/lib/ruby/getting-started/introduction.html)
HONEYBADGER_API_KEY="Optional"
Expand Down Expand Up @@ -197,3 +209,12 @@ COVERAGE="false"

# MLH API URL override (optional)
MLH_API_BASE_URL=

# TEMPORARY MLH Core bridge (see app/services/authentication/mlh_core_bridge.rb).
# Setting MLH_OAUTH_BASE_URL routes MLH sign-in through Core instead of my.mlh.io.
MLH_OAUTH_BASE_URL=
# External sign-in return; disabled unless explicitly set to true. To be replaced
# by OIDC third-party initiated login (target_link_uri).
FOREM_EXTERNAL_RETURN_ENABLED=false
# Full HTTPS endpoint, no query or fragment. Core integration: https://www.mlh.test/oauth/dev
FOREM_EXTERNAL_RETURN_URL=
10 changes: 10 additions & 0 deletions Containerfile
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,16 @@ FROM ghcr.io/forem/ruby:3.3.0@sha256:9cda49a45931e9253d58f7d561221e43bd0d47676b8
# HOTFIX: Remove broken nodesource list from base image to unblock apt update
RUN rm -f /etc/apt/sources.list.d/nodesource.list

# HOTFIX: bullseye LTS ended 2026-08-31 and the bullseye-security pool is
# being purged from deb.debian.org / security.debian.org (their CDN edges 404
# on .deb files the index still lists) while archive.debian.org has not
# picked the suite up yet. snapshot.debian.org keeps every package forever, so
# pin the security source to a snapshot from the last LTS day until the base
# image moves to a supported Debian release.
RUN find /etc/apt -type f \( -name "sources.list" -o -name "*.list" -o -name "*.sources" \) \
-exec sed -i "s|http://[a-z.]*debian.org/debian-security|http://snapshot.debian.org/archive/debian-security/20260831T000000Z|g" {} + \
&& echo 'Acquire::Check-Valid-Until "false";' > /etc/apt/apt.conf.d/99snapshot

FROM base as builder

# This is provided by BuildKit
Expand Down
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ gem "jbuilder", "~> 2.11" # Create JSON structures via a Builder-style DSL
gem "js-routes", "~> 2.2" # Brings Rails named routes to javascript
gem "jsbundling-rails", "~> 1.2" # A Rails plugin to bundle JavaScript
gem "jsonapi-serializer", "~> 2.2" # Serializer for Ruby objects
gem "jwt", "2.10.3" # Verify delegated access tokens
gem "kaminari", "~> 1.2" # A Scope and Engine based, clean, powerful, customizable and sophisticated paginator
gem "katex", "~> 0.9.0" # This rubygem enables you to render TeX math to HTML using KaTeX. It uses ExecJS under the hood
gem "liquid", "~> 5.4" # A secure, non-evaling end user template engine with aesthetic markup
Expand Down
1 change: 1 addition & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1187,6 +1187,7 @@ DEPENDENCIES
js-routes (~> 2.2)
jsbundling-rails (~> 1.2)
jsonapi-serializer (~> 2.2)
jwt (= 2.10.3)
kamal (~> 2.3)
kaminari (~> 1.2)
katex (~> 0.9.0)
Expand Down
61 changes: 56 additions & 5 deletions app/controllers/api/v1/api_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ class ApiController < ApplicationController
rescue_from ActiveRecord::RecordNotFound, with: :error_not_found

rescue_from Pundit::NotAuthorizedError, with: :error_unauthorized
rescue_from DelegatedAccess::Errors::InvalidToken, with: :error_unauthorized
# Only a trust-endpoint failure with no usable cached JWKS reaches this handler.
# Invalid tokens, including an unknown key with a fresh cache, remain unauthorized.
rescue_from DelegatedAccess::Errors::Unavailable, with: :error_delegated_access_unavailable

protected

Expand All @@ -34,6 +38,10 @@ def error_not_found
render json: { error: "not found", status: 404 }, status: :not_found
end

def error_delegated_access_unavailable
render json: { error: "delegated access unavailable", status: 503 }, status: :service_unavailable
end

# @note This method is used in ApplicationController within the
# `verify_private_forem` method (read more in annotations there).
# It uses `authenticate_with_api_key_or_current_user!` under the
Expand All @@ -45,6 +53,46 @@ def authenticate!
authenticate_with_api_key_or_current_user!
end

# Bearer tokens are only interpreted when delegated access is enabled.
# Otherwise the Authorization header is ignored exactly as it was before
# this feature existed, so clients that send one alongside an api-key or
# session keep working on instances that never turn this on.
def authenticate_with_delegated_access
config = Rails.application.config.x.delegated_access
return unless config.enabled

token = delegated_bearer_token
return unless token

claims = config.verifier.verify(token)

identity = Identity.includes(:user).where(
provider: config.identity_provider,
uid: claims.subject,
user_id: claims.owner_id,
).sole
user = identity.user
if user&.spam_or_suspended? || !user&.registered?
raise DelegatedAccess::Errors::InvalidToken
end

@user = @authenticated_user = user
rescue ActiveRecord::RecordNotFound, ActiveRecord::SoleRecordExceeded
raise DelegatedAccess::Errors::InvalidToken
end

def delegated_bearer_token
authorization = request.authorization
return unless authorization&.match?(/\bBearer\b/i)

scheme, token = authorization.split(" ", 2)
unless scheme.casecmp?("Bearer") && token.present? && token.exclude?(",")
raise DelegatedAccess::Errors::InvalidToken
end

token
end

# @note This method is performing both authentication and authorization. The user suspended
# should be something added to the corresponding pundit policy.
def authenticate_with_api_key!
Expand Down Expand Up @@ -82,12 +130,12 @@ def authenticate_with_api_key_or_current_user
@user = authenticate_with_api_key || current_user
end

def authenticated_user
return @authenticated_user if defined?(@authenticated_user)
def authenticated_user
return @authenticated_user if defined?(@authenticated_user)

user = authenticate_with_api_key || current_user
@authenticated_user = user&.spam_or_suspended? ? nil : user
end
user = authenticate_with_api_key || current_user
@authenticated_user = user&.spam_or_suspended? ? nil : user
end
helper_method :authenticated_user

def authorize_super_admin
Expand Down Expand Up @@ -115,6 +163,9 @@ def pundit_user
end

def authenticate_with_api_key
delegated_user = authenticate_with_delegated_access
return delegated_user if delegated_user

api_key = request.headers["api-key"]
return unless api_key

Expand Down
124 changes: 118 additions & 6 deletions app/controllers/omniauth_callbacks_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ def failure
"uri:#{error.try(:error_uri)}",
"provider:#{request.env['omniauth.strategy'].name}",
"origin:#{request.env['omniauth.strategy.origin']}",
"params:#{request.env['omniauth.params']}",
"params:#{loggable_omniauth_params}",
],
)

Expand All @@ -58,8 +58,115 @@ def passthru
redirect_to root_path(signin: "true")
end

def confirm_account_switch
pending = session.delete("pending_account_switch")
target = pending && User.find_by(id: pending["user_id"])

if invalid_pending_switch?(pending, target)
flash[:alert] = I18n.t("omniauth_callbacks_controller.account_switch_expired")
return redirect_to root_path
end

payload = decrypt_pending_switch(pending)
return redirect_to(root_path) unless payload

@user = Authentication::Authenticator.call(OmniAuth::AuthHash.new(payload), expected_user: target)
return redirect_to(root_path) unless user_persisted_and_valid? && @user.confirmed?

complete_account_switch(pending)
rescue ::Authentication::Errors::Ineligible, ::Authentication::Errors::PreviouslySuspended,
::Authentication::Errors::SpammyEmailDomain => e
flash[:global_notice] = e.message
redirect_to root_path
rescue ActiveRecord::RecordInvalid => e
flash[:alert] = e.record&.errors&.full_messages&.join(", ").presence ||
I18n.t("omniauth_callbacks_controller.log_in_error", e: e.message)
redirect_to new_user_registration_url
rescue StandardError => e
Honeybadger.notify(e)
flash[:alert] = I18n.t("omniauth_callbacks_controller.log_in_error", e: e)
redirect_to new_user_registration_url
end

def cancel_account_switch
session.delete("pending_account_switch")
redirect_to root_path
end

private

# Cross-account sign-in: the caller is signed in as one user and the incoming
# identity resolves to another. Nothing is persisted until the person confirms
# on the interstitial; the OmniAuth payload is held encrypted in the session.
def stage_account_switch(target_user, provider)
session["pending_account_switch"] = {
"user_id" => target_user.id,
"provider" => provider.to_s,
"payload" => account_switch_encryptor.encrypt_and_sign(stageable_auth_payload(provider), expires_in: 15.minutes),
"return_context" => Authentication::ExternalReturn.capture(request.env["omniauth.params"]),
"staged_at" => Time.current.iso8601
}
@switch_target_username = target_user.username
render :account_switch
end

def invalid_pending_switch?(pending, target)
pending.nil? || stale_switch?(pending) || target.nil? || target.spam_or_suspended?
end

# OmniAuth request params can carry values we filter from logs (see
# config/initializers/filter_parameter_logging.rb); apply the same filter to
# telemetry tags. Non-Hash values are passed through untouched.
def loggable_omniauth_params
params = request.env["omniauth.params"]
return params unless params.is_a?(Hash)

ActiveSupport::ParameterFilter.new(Rails.application.config.filter_parameters).filter(params)
end

def stale_switch?(pending)
Time.zone.iso8601(pending["staged_at"].to_s) < 15.minutes.ago
rescue ArgumentError, TypeError
true
end

# Providers that must not hold OAuth credentials beyond the callback request
# get them blanked before staging; everyone else keeps them (encrypted) so the
# identity can be created with a token once the switch is confirmed.
def stageable_auth_payload(provider)
payload = request.env["omniauth.auth"].to_hash
payload["credentials"] = { "expires" => false } unless Authentication::Providers.get!(provider).persist_credentials?
payload
end

def account_switch_encryptor
key = Rails.application.key_generator.generate_key("account-switch-payload", 32)
ActiveSupport::MessageEncryptor.new(key, cipher: "aes-256-gcm", serializer: JSON)
end

# Returns the staged OmniAuth payload, or nil if it expired, was tampered
# with, or no longer matches the provider recorded at staging time.
def decrypt_pending_switch(pending)
payload = account_switch_encryptor.decrypt_and_verify(pending["payload"])
payload if payload&.fetch("provider", nil) == pending["provider"]
rescue ActiveSupport::MessageEncryptor::InvalidMessage
nil
end

def complete_account_switch(pending)
sign_out(current_user) if current_user
sign_in(@user, event: :authentication)

set_flash_message(:notice, :success, kind: pending["provider"].to_s.titleize) if is_navigational_format?
@user.update_tracked_fields!(request)
remember_me(@user)

return_url = Authentication::ExternalReturn.resolve(pending["return_context"])
return redirect_to(return_url, allow_other_host: true) if return_url

sign_in_and_redirect(@user, event: :authentication)
end

def callback_for(provider)
auth_payload = request.env["omniauth.auth"]
cta_variant = request.env["omniauth.params"]["state"].to_s
Expand All @@ -84,7 +191,10 @@ def callback_for(provider)

user_agent = request.user_agent

if ApplicationConfig["AUTH_TEST_USER_IDS"].present? && ApplicationConfig["AUTH_TEST_USER_IDS"].split(",").include?(@user.id.to_s)
if (external_return = Authentication::ExternalReturn.redirect_url_for(request.env["omniauth.params"]))
sign_in(@user, event: :authentication)
redirect_to external_return, allow_other_host: true
elsif ApplicationConfig["AUTH_TEST_USER_IDS"].present? && ApplicationConfig["AUTH_TEST_USER_IDS"].split(",").include?(@user.id.to_s)
token = generate_auth_token(@user)
test_path = ApplicationConfig["AUTH_TEST_USER_REDIRECT_PATH"] || "/menu"
redirect_to "#{test_path}?jwt=#{token}"
Expand All @@ -111,7 +221,7 @@ def callback_for(provider)
# Handle error conditions.
session["devise.#{provider}_data"] = request.env["omniauth.auth"]
user_errors = @user.errors.full_messages

Honeybadger.context({
username: @user.username,
user_id: @user.id,
Expand All @@ -120,11 +230,14 @@ def callback_for(provider)
user_errors: user_errors
})
Honeybadger.notify("Omniauth log in error")

flash[:alert] = user_errors
redirect_to new_user_registration_url
end
rescue ::Authentication::Errors::PreviouslySuspended, ::Authentication::Errors::SpammyEmailDomain => e
rescue ::Authentication::Errors::AccountSwitchConfirmation => e
stage_account_switch(e.target_user, provider)
rescue ::Authentication::Errors::Ineligible, ::Authentication::Errors::PreviouslySuspended,
::Authentication::Errors::SpammyEmailDomain => e
flash[:global_notice] = e.message

redirect_to root_path
Expand All @@ -136,7 +249,6 @@ def callback_for(provider)
flash[:alert] = I18n.t("omniauth_callbacks_controller.log_in_error", e: e)
redirect_to new_user_registration_url
end


def user_persisted_and_valid?
@user.persisted? && @user.valid?
Expand Down
2 changes: 2 additions & 0 deletions app/javascript/packs/onboardingRedirectCheck.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ HTMLDocument.prototype.ready = new Promise((resolve) => {
});

function redirectableLocation() {
if (document.querySelector('[data-account-switch-confirmation]')) return false;

return ![
'/onboarding',
'/signout_confirm',
Expand Down
11 changes: 10 additions & 1 deletion app/services/authentication/authenticator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,11 @@ def self.call(...)
end

# auth_payload is the payload schema, see https://github.com/omniauth/omniauth/wiki/Auth-Hash-Schema
def initialize(auth_payload, current_user: nil, cta_variant: nil)
def initialize(auth_payload, current_user: nil, cta_variant: nil, expected_user: nil)
@provider = load_authentication_provider(auth_payload)

@current_user = current_user
@expected_user = expected_user
@cta_variant = cta_variant
end

Expand All @@ -51,6 +52,7 @@ def call

ActiveRecord::Base.transaction do
user = proper_user(identity)
verify_expected_user!(user)

user = if user.nil?
find_or_create_user!
Expand Down Expand Up @@ -187,6 +189,13 @@ def verified_email_user
user&.confirmed? ? user : nil
end

def verify_expected_user!(user)
return unless @expected_user
return if user == @expected_user && user.confirmed? && !user.spam_or_suspended?

raise ::Authentication::Errors::Ineligible
end

def proper_user(identity)
if current_user
Rails.logger.debug { "Current user exists: #{current_user.id}" }
Expand Down
Loading
Loading