Skip to content
Open
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
26 changes: 25 additions & 1 deletion app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ def current_user = nil # TODO: this is a temp hack to fix partials until /backen

helper_method :detected_country_alpha2

prepend_before_action :set_current_identity_session
before_action :invalidate_v1_sessions, :authenticate_identity!, :set_honeybadger_context

before_action :set_paper_trail_whodunnit
Expand Down Expand Up @@ -36,6 +37,11 @@ def authenticate_identity!
unless identity_signed_in?
return if controller_name == "onboardings"

# The active account can expire while its siblings are still live. We never
# promote one silently, so send the user to pick instead of showing a login
# screen that makes it look like the whole browser was signed out.
return redirect_to browser_accounts_path if other_live_accounts_in_browser?

if request.xhr?
redirect_to welcome_path
else
Expand Down Expand Up @@ -110,5 +116,23 @@ def current_onboarding_step

private

def touch_session_last_seen_at = current_session&.touch_last_seen_at
def set_current_identity_session
# ||= so an explicit account selection made by an earlier prepended callback
# survives — see OidcAccountSelection. That selection, not the browser's
# active account, is what auth_time/amr/acr must describe.
Current.identity_session ||= current_session
end

# Gated on the same flag check /accounts itself uses, so with the flag off both
# answer false and there is nothing to bounce between.
def other_live_accounts_in_browser?
return false unless account_chooser_available?

current_browser_session&.live_identity_sessions&.exists? || false
end

def touch_session_last_seen_at
current_session&.touch_last_seen_at
current_browser_session&.touch_last_seen_at
end
end
161 changes: 161 additions & 0 deletions app/controllers/browser_accounts_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
class BrowserAccountsController < ApplicationController
layout "logged_out"

FEATURE_FLAG = :multi_account_sessions_2026_07_24

# Reachable with no active account: signing one account out leaves the browser
# session alive with the others, and we never auto-promote a sibling.
skip_before_action :authenticate_identity!

before_action :require_browser_session
before_action :require_feature_flag

def index
@accounts = accounts
@pending_token = params[:pending]
@preselect_public_id = params[:preselect]
@at_account_limit = current_browser_session.at_account_limit?
end

def switch
target = find_account(params[:id])
return account_not_found if target.nil?

switch_account!(target)
remember_pending_selection(target)

resume_or(root_path)
end

# Leaves mid-flow to authenticate a different account, then comes back. The
# parked request travels as an opaque handle in return_to, so the login flow
# itself needs no knowledge of any of this.
def add
if current_browser_session.at_account_limit?
flash[:error] = t("accounts.limit_error", max: BrowserSession::MAX_ACCOUNTS)
return redirect_to browser_accounts_path(pending: params[:pending])
end

# Lets LoginsController#ensure_no_user! through for this flow only.
session[:adding_account] = true

return_to = params[:pending].present? ? resume_browser_account_path(pending: params[:pending]) : root_path
redirect_to login_path(return_to: return_to)
end

def destroy
target = find_account(params[:id])
return account_not_found if target.nil?

result = remove_account!(target)
return account_not_found if result.nil?

flash[:info] = "Signed out of #{target.identity.primary_email}."

if result == :signed_out
redirect_to welcome_path
else
redirect_to browser_accounts_path(pending: params[:pending])
end
end

def resume
pending = PendingAuthorization.consume!(
token: params[:pending],
browser_session: current_browser_session
)

if pending.nil?
flash[:error] = "That sign-in request expired. Start again from the app you were signing into."
return redirect_to root_path
end

session.delete(:adding_account)

# Authenticating an account through "use another account" *is* the choice.
# Without recording it the replayed request resolves as ambiguous again and
# bounces the user straight back to the chooser they just came from.
remember_selection_for(pending, current_session)

redirect_to rebuilt_request_path(pending), allow_other_host: false
end

private

def accounts
current_browser_session
.live_identity_sessions
.includes(:identity, :login_attempt)
end

def find_account(public_id)
return nil if public_id.blank?

accounts.find { |session| session.identity.public_id == public_id }
end

def account_not_found
flash[:error] = "That account isn't signed in on this browser."
redirect_to browser_accounts_path
end

def require_browser_session
return if current_browser_session&.live_identity_sessions&.exists?

redirect_to welcome_path
end

def require_feature_flag
return if Flipper.enabled?(FEATURE_FLAG, current_identity)

redirect_to root_path
end

# Recording the choice before resuming means the replayed request resolves to
# this account without needing to carry it in the URL.
def remember_pending_selection(identity_session)
pending = PendingAuthorization.active.find_by(token: params[:pending])
return if pending.nil? || pending.browser_session_id != current_browser_session.id

remember_selection_for(pending, identity_session)
end

def remember_selection_for(pending, identity_session)
return if identity_session.nil?

kind, ref = client_reference_for(pending)
return if ref.blank?

current_browser_session.remember_selection!(kind: kind, ref: ref, identity: identity_session.identity)
end

def client_reference_for(pending)
case pending.kind
when "oidc"
[ "oidc", pending.payload.dig("params", "client_id") ]
when "saml"
[ "saml", pending.payload["entity_id"] ]
end
end

def resume_or(fallback)
return redirect_to(resume_browser_account_path(pending: params[:pending])) if params[:pending].present?

redirect_to fallback
end

# Only ever rebuilds a request we parked ourselves, and only to the two
# endpoints that can park one.
def rebuilt_request_path(pending)
case pending.kind
when "oidc"
authorize_params = pending.payload["params"] || {}
"/oauth/authorize?#{authorize_params.to_query}"
when "saml"
url = pending.payload["url"].to_s
url.start_with?("/saml/auth") ? url : root_path
else
root_path
end
end
end
180 changes: 180 additions & 0 deletions app/controllers/concerns/oidc_account_selection.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
# frozen_string_literal: true

# Account selection for the OIDC authorize endpoint.
#
# doorkeeper-openid_connect handles prompt=login and max_age for us (once
# auth_time comes from the right session), but it has no id_token_hint support,
# no way to return account_selection_required, and its select_account hook is
# unconfigured — which currently makes prompt=select_account a hard error.
#
# This runs ahead of the gem's authenticate_resource_owner! chain: if it
# redirects, the gem never sees the request.
module OidcAccountSelection
extend ActiveSupport::Concern

CLIENT_KIND = "oidc"

# Everything the authorize endpoint (Doorkeeper plus openid_connect)
# understands. Parking an allowlist rather than the raw request keeps Rails
# form cruft — authenticity_token, commit, _method — out of the replayed URL.
PARKED_AUTHORIZE_PARAMS = %w[
client_id redirect_uri response_type response_mode scope state nonce
prompt max_age login_hint id_token_hint acr_values claims claims_locales
display ui_locales code_challenge code_challenge_method
].freeze

included do
prepend_before_action :resolve_oidc_account_selection, only: [ :new, :create ]
end

# Consulted by Doorkeeper's resource_owner_authenticator so the grant is issued
# to the selected account rather than whichever one happens to be active.
def oidc_selected_identity
@oidc_selected_identity
end

private

def resolve_oidc_account_selection
return if params[:client_id].blank?

hint = IdTokenHintVerifier.call(params[:id_token_hint], audience: params[:client_id])
return render_oidc_selection_error(:invalid_request) if hint.invalid?

decision = AccountSelectionResolver.new(
browser_session: current_browser_session,
client_kind: CLIENT_KIND,
client_ref: params[:client_id],
prompt: params[:prompt].to_s.split(/ +/),
login_hint: params[:login_hint],
id_token_hint_subject: hint.subject
).call

case decision.action
when :login_required
# Nothing signed in here: fall through to the normal unauthenticated path
# (resource_owner_authenticator redirects to /oauth/welcome).
nil
when :error
render_oidc_selection_error(decision.error)
when :chooser
return select_oidc_account(decision) if account_chooser_available?

# Chooser disabled: behave exactly as before, using the active account.
apply_oidc_selection(current_session)
when :proceed
# A stale tab must not approve consent for an account other than the one
# whose data it displayed.
if request.post? && params[:selected_account].present? &&
params[:selected_account] != decision.identity_session.identity.public_id
return select_oidc_account(decision)
end

apply_oidc_selection(decision.identity_session)
end
end

def apply_oidc_selection(identity_session)
return if identity_session.nil?

@oidc_selected_identity = identity_session.identity
Current.identity_session = identity_session

# Step-up happens on a new request, where request-local selection is gone.
# Make the account that actually needs reauthentication active before the
# OIDC hook redirects there, otherwise step-up would verify a sibling.
activate_oidc_session_for_reauthentication(identity_session) if oidc_reauthentication_required?(identity_session)
end

def select_oidc_account(decision)
park_oidc_request_and_choose(preselect: decision.preselect_identity)
end

# Also the backstop for doorkeeper-openid_connect's select_account hook, which
# is reached only on paths this concern doesn't resolve itself.
def park_oidc_request_and_choose(preselect: nil)
pending = PendingAuthorization.park!(
browser_session: current_browser_session,
kind: CLIENT_KIND,
payload: oidc_pending_payload
)

redirect_to browser_accounts_path(
pending: pending.token,
preselect: preselect&.public_id
)
end

def oidc_pending_payload
authorization_params = request.request_parameters
.merge(request.query_parameters)
.slice(*PARKED_AUTHORIZE_PARAMS)

prompt_values = authorization_params["prompt"].to_s.split(/ +/).reject { |value| value == "select_account" }
if prompt_values.empty?
authorization_params.delete("prompt")
else
authorization_params["prompt"] = prompt_values.join(" ")
end

{ "params" => authorization_params }
end

# OIDC error responses belong on the client's redirect_uri, not on an HTML
# error page — the RP has to be able to see them.
def render_oidc_selection_error(name)
error_response =
if name == :invalid_request
Doorkeeper::OAuth::InvalidRequestResponse.new(
name: name,
state: params[:state],
redirect_uri: params[:redirect_uri]
)
else
Doorkeeper::OAuth::ErrorResponse.new(
name: name,
state: params[:state],
redirect_uri: params[:redirect_uri]
)
end

response.headers.merge!(error_response.headers)

if oidc_error_redirect_uri_valid?
redirect_to error_response.redirect_uri, allow_other_host: true
else
render json: { error: name }, status: :bad_request
end
end

def oidc_error_redirect_uri_valid?
application = Doorkeeper.config.application_model.find_by(uid: params[:client_id])
return false if application.nil? || params[:redirect_uri].blank?

Doorkeeper::OAuth::Helpers::URIChecker.valid_for_authorization?(
params[:redirect_uri],
application.redirect_uri
)
end

def oidc_reauthentication_required?(identity_session)
return true if params[:prompt].to_s.split(/ +/).include?("login")

max_age = params[:max_age].to_s
max_age_seconds = max_age.to_i
return false unless max_age == "0" || max_age_seconds.positive?

auth_time = [ identity_session.created_at, identity_session.last_step_up_at ].compact.max
auth_time.nil? || (Time.zone.now - auth_time) > [ 1, max_age_seconds ].max
end

# Goes through switch_account! rather than activate! so an RP-triggered change
# of active account rotates the cookie and lands in the audit log exactly like
# a user-initiated switch — it is the same observable change.
def activate_oidc_session_for_reauthentication(identity_session)
browser_session = current_browser_session
return if browser_session.nil? || browser_session.active_identity_session_id == identity_session.id

switch_account!(identity_session)
end
end
Loading