Files
sure/app/controllers/sessions_controller.rb
Max Barbare 700ad34eb1 feat: Introduce macOS app v0.1.0 (#2762)
* feat(desktop): scaffold Tauri 2 macOS shell with empty window

* feat(desktop): server store, URL normalization, and health-check helpers

* feat(desktop): IPC commands for server list/add/remove/health + active-server state

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf

* feat(desktop): native onboarding server picker with health check and remembered servers

* feat(desktop): vibrancy background, overlay titlebar, and inset traffic lights

* feat(desktop): native menu bar with standard shortcuts and menu events

* feat(desktop): webview→Rust bridge with native notifications

* fix(desktop): gate bridge injection on PageLoadEvent::Finished

Prevents double-injecting the bridge IIFE (once on Started, once on
Finished), which was duplicating every native notification.

* feat(desktop): Dock badge driven by webview attention count

* feat(desktop): launch-at-login autostart commands

* feat(desktop): sure:// deep link scheme with parse tests and navigation

* feat(desktop): preferences window with server switcher and launch-at-login

* docs(desktop): README for dev, release, signing/notarization, and deferred widget

* fix(desktop): remove dead New Window menu item

* fix(desktop): correct login route to /sessions/new

Rails uses `resources :sessions` (plural), so the login page is
/sessions/new, not the /session/new the plan assumed. Fixes an
immediate 404 when connecting to a server.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf

* feat(desktop): Sure-styled onboarding, draggable titlebar, and app content offset

- Restyle onboarding + prefs to match Sure's auth page: solid surface
  background, centered logomark, .form-field-style inputs, inverse primary
  button; theme-aware via prefers-color-scheme (design-system tokens).
- Add a draggable titlebar strip on bundled pages and inject one into the
  remote page so the window drags from the top everywhere.
- Inject a top offset on the logged-in app-layout root so the sidebar logo
  clears the macOS traffic lights.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf

* fix(desktop): de-dupe login navigation to prevent CSRF token/session race

connect() navigated to /sessions/new directly AND via the
active-server-changed event, which is also handled by a second listener
injected into the page by bridge.js. One connect fired multiple concurrent
GET /sessions/new requests, each minting a fresh session + CSRF token; the
form shown and the _sure_session finally stored could come from different
GETs, so the login POST failed 'Can't verify CSRF token authenticity'
intermittently. Route all navigation through a single window-level guard so
only the first request per server wins.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf

* fix(desktop): enable window drag permission; offset only the icon rail

- Add core:window:allow-start-dragging (+ show/set-focus, event emit/listen) to
  capabilities so data-tauri-drag-region actually drags the window on macOS.
- Offset only the 84px left icon rail (logomark) to clear the traffic lights
  instead of pushing the entire app-layout down; keep main content full-height.
- Drag strip z-index lowered below Sure's sticky headers so its controls stay
  clickable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf

* feat(desktop): persist active server and resume session on launch

- Persist the active server to the Keychain in set_active_server; active_server
  falls back to it so a relaunch knows where to go.
- On launch, auto-resume straight to the last server instead of showing the
  picker every time.
- Navigate to the server root (not /sessions/new): Rails serves the dashboard
  when the session cookie is still valid, or redirects to login when not — so a
  persisted session no longer forces a re-login.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf

* feat: desktop SSO via system browser with PKCE code exchange

Passkeys/WebAuthn don't work in an embedded WKWebView, so SSO now runs in
the system browser and hands a session back to the app securely.

Server (Rails):
- GET /auth/desktop/:provider — stashes a PKCE S256 challenge, hands off to
  OmniAuth (reusing the mobile auto-submit form). Passkeys work (real browser).
- openid_connect — for a linked identity in a desktop flow, mints a single-use,
  2-min, PKCE-bound one-time code and redirects to sure://sso/callback?code=...
  (unlinked identities are sent back with an error).
- GET /sessions/desktop_exchange — verifies the code + PKCE verifier
  (secure_compare), single-use (cache delete), then create_session_for; MFA is
  enforced at exchange time. Sets the normal web session cookie in the webview.
- Tests: happy path + single-use, wrong-verifier rejection, missing challenge.

Desktop (Tauri):
- start_sso command: generates PKCE, opens the browser, stores the verifier.
- sure://sso/callback deep link -> webview navigates to desktop_exchange with
  the verifier (never sent through the deep link, so an intercepted code is
  useless).
- bridge.ts intercepts SSO provider form submits and routes them to start_sso;
  password login stays in the webview.
- remote.json capability: minimal IPC (drag, event bridge, prefs window,
  start_sso) for the remote Sure origin — no fs/shell/http.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf

* fix(desktop): correct remote IPC capability + handle menu in Rust + drag fallback

Root cause of prefs/switch-server/SSO/drag doing nothing on the logged-in
page: the remote-bridge capability's remote.urls ('https://*') did not match
the server origin, so all IPC (event listen, invoke, drag command) was denied.
Per Tauri v2, window.__TAURI__ is injected on remote pages only with
withGlobalTauri (set) AND a matching remote.urls; patterns need a path
wildcard.

- remote.json: urls -> https://*/**, http://*/** (+ bare host) so any server
  origin matches.
- menu.rs: Preferences and Switch Server now show the prefs window directly in
  Rust (no dependency on remote-page IPC); Switch Server moved from Window to
  the App menu.
- bridge.ts: drops the menu-event listeners (Rust owns them), adds a
  startDragging mousedown fallback for the drag strip, logs diagnostics, and
  reports start_sso success/failure to the console.
- main.ts: drops the now-unused menu listeners.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf

* fix(desktop): SSO via event (remote can't invoke commands), disk-backed server store

Diagnostics confirmed window.__TAURI__ + IPC work on the remote page, but a
remote origin cannot invoke custom commands ('start_sso not allowed. Plugin
not found'). Events are permitted, so SSO now goes through an event.

- SSO: bridge emits 'sure://start-sso'; Rust listens and runs begin_sso
  (opens the system browser). start_sso command kept for local use.
- servers: mirror the server list + active server to a JSON file in
  Application Support as a fallback — Keychain items don't persist for
  unsigned builds, which was wiping the saved server on relaunch.
- remote.json: add notification:default (Sure's PWA was requesting it and
  erroring).
- menu: log whether the prefs window is present when Preferences/Switch
  Server fire, to diagnose the no-op.
- main: log the persisted active server on boot.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf

* fix(desktop): drag the top band on every page via mousedown, not a z-indexed strip

The fixed drag strip sat below Sure's sticky headers (z-10) so it worked only
on pages without a top header. Replace it with a document-level mousedown in the
top ~34px that starts a window drag unless the target is an interactive element
— so dragging works on all pages, Sure's titlebar controls stay clickable, and
main content isn't pushed down.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf

* ci(desktop): tag-triggered GitHub Actions release for universal unsigned .dmg

- .github/workflows/desktop-release.yml: on a 'desktop-v*' tag, build the
  universal (Apple Silicon + Intel) .dmg on a macOS runner via tauri-action and
  publish it to a GitHub Release with unsigned-install instructions.
- README: universal build command, the tag-based release process, and the
  Gatekeeper 'Open Anyway' / xattr steps for end users.
- Drop the unused iOS/Android icon sets (macOS build only needs icon.icns).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf

* ci(desktop): rolling desktop-latest build on desktop/ changes, not manual tags

Replaces the manual desktop-v* tag release with a path-filtered workflow that
builds only when desktop/ changes on main and publishes to a single rolling
'desktop-latest' prerelease with a stable Sure.dmg filename — one permanent
download URL, and the file changes only when the desktop code does.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf

* ci(desktop): tag-driven versioned releases; tag is the single source of version

Revert to manual version tags (desktop-v*) for explicit version control, but
derive the app/.dmg version from the tag so package.json + tauri.conf.json are
synced automatically in CI — no manual version-file edits. Each tag produces its
own versioned GitHub Release with the universal unsigned .dmg.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf

* ci(desktop): release entirely from GitHub via workflow_dispatch version input

Make the GitHub Action the single tool to version + deploy the desktop app:
Run workflow -> enter a version -> it syncs the version, builds the universal
unsigned .dmg, and creates the desktop-v<version> tag + Release. Refuses to
re-release an existing version; marks pre-release versions accordingly. Tag
push (desktop-v*) still works as a secondary trigger.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf

* ci(desktop): publish releases with make_latest:false so they don't hijack the repo's Latest badge

Desktop is a secondary artifact, not the main product. Build with tauri-action,
then publish via action-gh-release with make_latest:false so the repo's 'Latest
release' badge stays on the main app's v* release. Separate desktop-v* tag
namespace already keeps it out of the v* publish workflow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf

* fix(desktop): address PR review feedback (security + correctness)

Security:
- workflow: pass workflow_dispatch version via env (no shell injection); pin all
  third-party actions to commit SHAs.
- SSO: gate deep-link navigation and begin_sso to servers the user has saved
  (is_known_server), so a rogue page/deep link can't drive them.
- desktop_exchange is now POST (verifier in body, not URL/logs); CSRF skipped
  since the single-use PKCE code is the protection.
- desktop_sso_start validates the code_challenge is a 43-char base64url digest.
- desktop_exchange claims the one-time code atomically (delete-and-check) to
  close the read/delete TOCTOU.
- failure: return desktop SSO errors to the app via sure://sso/callback?error.

Correctness / stability:
- prefs window hides on close instead of being destroyed, so the menu can
  reopen it.
- servers.rs: on-disk store is authoritative (file-first read), atomic writes
  (temp + rename).
- main.ts/prefs.ts: try/catch around add/set/remove/active_server and boot; add
  a shared serverErrorMessage helper (no duplicated substring checks).
- vite.config.ts: derive dir from import.meta.url (ESM has no __dirname).
- bridge.ts: coalesce MutationObserver scans to one per frame.
- README: notarization example uses the universal .dmg name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf

* fix(desktop): scope remote IPC to server origins at runtime, drop wildcard capability

Resolves the remaining security finding: the static remote.json granted Tauri
IPC to any http(s) origin (https://*). Remove it and instead add a capability
scoped to each server's exact origin at runtime (CapabilityBuilder +
add_capability), granting only the minimal permissions the bridge needs, for
saved/active servers on startup and for the target in set_active_server. No
origin outside the user's configured servers can access IPC.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf

* fix(desktop): add runtime per-origin IPC capability (grant_server_capability)

Implements the runtime-scoped capability that replaces the removed wildcard
remote.json: CapabilityBuilder scoped to each server's exact origin, added via
add_capability for saved/active servers at startup and in set_active_server.
(Split from the previous commit, which only recorded the remote.json removal.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf

* ci(desktop): harden release workflow (no shared caches, environment gate)

Address two release-workflow security findings:
- Remove cache: npm and the swatinem/rust-cache step so a poisoned Actions
  cache written by another workflow can't flow into a published .dmg (P0).
- Add 'environment: release' to the build job so publishing can require manual
  approval and scope secrets to release runs (P1).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf

* fix(desktop): remove transparency code and fix relaunch behavior

* fix(desktop): fix PR review findings; adjust app notarization path, use Sure theme tokens instead of hardcoding values

* ci(desktop): switch release from independant versioning to using Sure's publishing workflow, releasing and versioning with every main app release

* fix(desktop): restrict CSP as much as possible while maintaining functionality; allow bundled scripts, Tauri IPC, inline styles; deny wildcards

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 07:44:06 +02:00

504 lines
18 KiB
Ruby

class SessionsController < ApplicationController
extend SslConfigurable
before_action :set_session, only: :destroy
skip_authentication only: %i[index new create openid_connect failure post_logout mobile_sso_start desktop_sso_start desktop_exchange]
# The desktop exchange is a cross-context POST from the app's webview that
# can't carry a CSRF token; the single-use, PKCE-bound one-time code is the
# protection instead (same model as the OAuth callback).
skip_forgery_protection only: :desktop_exchange
layout "auth"
# Handle GET /sessions (usually from browser back button)
def index
redirect_to new_session_path
end
def new
store_pending_invitation_if_valid
# Clear any stale mobile/desktop SSO session flag from an abandoned flow
session.delete(:mobile_sso)
session.delete(:desktop_sso)
begin
demo = Rails.application.config_for(:demo)
@prefill_demo_credentials = demo_host_match?(demo)
if @prefill_demo_credentials
@email = params[:email].presence || demo["email"]
@password = params[:password].presence || demo["password"]
else
@email = params[:email]
@password = params[:password]
end
rescue RuntimeError, Errno::ENOENT, Psych::SyntaxError
# Demo config file missing or malformed - disable demo credential prefilling
@prefill_demo_credentials = false
@email = params[:email]
@password = params[:password]
end
end
def create
# Clear any stale mobile SSO session flag from an abandoned mobile flow
session.delete(:mobile_sso)
user = nil
if AuthConfig.local_login_enabled?
user = User.authenticate_by(email: params[:email], password: params[:password])
else
# Local login is disabled. Only allow attempts when an emergency super-admin
# override is enabled and the email belongs to a super-admin.
if AuthConfig.local_admin_override_enabled?
candidate = User.find_by(email: params[:email])
unless candidate&.super_admin?
redirect_to new_session_path, alert: t("sessions.create.local_login_disabled")
return
end
user = User.authenticate_by(email: params[:email], password: params[:password])
else
redirect_to new_session_path, alert: t("sessions.create.local_login_disabled")
return
end
end
if user
if user.otp_required?
log_super_admin_override_login(user)
session[:mfa_user_id] = user.id
redirect_to verify_mfa_path
else
log_super_admin_override_login(user)
@session = create_session_for(user)
flash[:notice] = t("invitations.accept_choice.joined_household") if accept_pending_invitation_for(user)
redirect_to root_path
end
else
flash.now[:alert] = t(".invalid_credentials")
render :new, status: :unprocessable_entity
end
end
def destroy
user = Current.user
id_token = session[:id_token_hint]
login_provider = session[:sso_login_provider]
# Find the identity for the provider used during login, with fallback to first if session data lost
oidc_identity = if login_provider.present?
user.oidc_identities.find_by(provider: login_provider)
else
user.oidc_identities.first
end
# Destroy local session
@session.destroy
session.delete(:id_token_hint)
session.delete(:sso_login_provider)
# Check if we should redirect to IdP for federated logout
if oidc_identity && id_token.present?
idp_logout_url = build_idp_logout_url(oidc_identity, id_token)
if idp_logout_url
SsoAuditLog.log_logout_idp!(user: user, provider: oidc_identity.provider, request: request)
redirect_to idp_logout_url, allow_other_host: true
return
end
end
# Standard local logout
SsoAuditLog.log_logout!(user: user, request: request)
redirect_to new_session_path, notice: t(".logout_successful")
end
# Handle redirect back from IdP after federated logout
def post_logout
redirect_to new_session_path, notice: t(".logout_successful")
end
def mobile_sso_start
provider = params[:provider].to_s
configured_providers = Rails.configuration.x.auth.sso_providers.map { |p| p[:name].to_s }
unless configured_providers.include?(provider)
mobile_sso_redirect(error: "invalid_provider", message: "SSO provider not configured")
return
end
device_params = params.permit(:device_id, :device_name, :device_type, :os_version, :app_version)
unless device_params[:device_id].present? && device_params[:device_name].present? && device_params[:device_type].present?
mobile_sso_redirect(error: "missing_device_info", message: "Device information is required")
return
end
session[:mobile_sso] = {
device_id: device_params[:device_id],
device_name: device_params[:device_name],
device_type: device_params[:device_type],
os_version: device_params[:os_version],
app_version: device_params[:app_version]
}
# Render auto-submitting form to POST to OmniAuth (required by omniauth-rails_csrf_protection)
@provider = provider
render layout: false
end
# Entry point for desktop-app SSO, opened in the system browser so passkeys
# work. Stashes the desktop PKCE challenge, then hands off to OmniAuth exactly
# like the mobile flow (reusing its auto-submitting form).
def desktop_sso_start
provider = params[:provider].to_s
configured_providers = Rails.configuration.x.auth.sso_providers.map { |p| p[:name].to_s }
unless configured_providers.include?(provider)
redirect_to new_session_path, alert: t("sessions.openid_connect.failed")
return
end
code_challenge = params[:code_challenge].to_s
# Require a well-formed PKCE (S256) challenge — a 43-char base64url SHA-256
# digest — so the one-time code returned via the custom URL scheme can only
# be redeemed by the app instance that started the flow (it alone holds the
# verifier). Validate the format before copying it into session state.
unless code_challenge.match?(/\A[A-Za-z0-9_-]{43}\z/)
redirect_to new_session_path, alert: t("sessions.openid_connect.failed")
return
end
session[:desktop_sso] = { code_challenge: code_challenge }
@provider = provider
render :mobile_sso_start, layout: false
end
# Exchanges the single-use, PKCE-bound code (delivered to the desktop app via
# sure://sso/callback) for a real web session in the app's own webview.
def desktop_exchange
code = params[:code].to_s
code_verifier = params[:code_verifier].to_s
cache_key = "desktop_sso:#{code}"
data = Rails.cache.read(cache_key)
# Atomically claim the code: only the request whose delete actually removes
# the entry may proceed, so two concurrent exchanges can't both succeed
# (delete returns false for the loser). Redis/MemoryStore both report this.
claimed = Rails.cache.delete(cache_key)
if code.blank? || code_verifier.blank? || data.blank? || !claimed
redirect_to new_session_path, alert: t("sessions.openid_connect.failed")
return
end
data = data.with_indifferent_access
expected_challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false)
unless ActiveSupport::SecurityUtils.secure_compare(expected_challenge, data[:code_challenge].to_s)
redirect_to new_session_path, alert: t("sessions.openid_connect.failed")
return
end
user = User.find_by(id: data[:user_id])
unless user
redirect_to new_session_path, alert: t("sessions.openid_connect.failed")
return
end
if user.otp_required?
session[:mfa_user_id] = user.id
redirect_to verify_mfa_path
else
@session = create_session_for(user)
flash[:notice] = t("invitations.accept_choice.joined_household") if accept_pending_invitation_for(user)
redirect_to root_path
end
end
def openid_connect
auth = request.env["omniauth.auth"]
# Nil safety: ensure auth and required fields are present
unless auth&.provider && auth&.uid
redirect_to new_session_path, alert: t("sessions.openid_connect.failed")
return
end
# Security fix: Look up by provider + uid, not just email
oidc_identity = OidcIdentity.find_by(provider: auth.provider, uid: auth.uid)
if oidc_identity
# Existing OIDC identity found - authenticate the user
user = oidc_identity.user
oidc_identity.record_authentication!
oidc_identity.sync_user_attributes!(auth)
# Log successful SSO login
SsoAuditLog.log_login!(user: user, provider: auth.provider, request: request)
# Mobile SSO: issue Doorkeeper tokens and redirect to app
if session[:mobile_sso].present?
if user.otp_required?
session.delete(:mobile_sso)
mobile_sso_redirect(error: "mfa_not_supported", message: "MFA users should sign in with email and password")
else
handle_mobile_sso_callback(user)
end
return
end
# Desktop SSO: hand a single-use, PKCE-bound code back to the desktop app,
# which exchanges it for a normal web session. MFA is enforced later, at
# exchange time (the desktop webview can complete MFA), so it is supported.
if session[:desktop_sso].present?
handle_desktop_sso_callback(user)
return
end
# Store id_token and provider for RP-initiated logout
session[:id_token_hint] = auth.credentials&.id_token if auth.credentials&.id_token
session[:sso_login_provider] = auth.provider
# MFA check: If user has MFA enabled, require verification
if user.otp_required?
session[:mfa_user_id] = user.id
redirect_to verify_mfa_path
else
@session = create_session_for(user)
flash[:notice] = t("invitations.accept_choice.joined_household") if accept_pending_invitation_for(user)
redirect_to root_path
end
else
# Mobile SSO with no linked identity - cache pending auth and redirect
# back to the app with a linking code so the user can link or create an account
if session[:mobile_sso].present?
handle_mobile_sso_onboarding(auth)
return
end
# Desktop SSO with no linked identity: send the app back to the login
# screen with an error. Linking/JIT creation still happens through the
# normal web flow; the desktop handoff only resumes already-linked users.
if session[:desktop_sso].present?
session.delete(:desktop_sso)
redirect_to "sure://sso/callback?error=account_not_linked", allow_other_host: true
return
end
# No existing OIDC identity - need to link to account
# Store auth data in session and redirect to linking page
session[:pending_oidc_auth] = {
provider: auth.provider,
uid: auth.uid,
email: auth.info&.email,
name: auth.info&.name,
first_name: auth.info&.first_name,
last_name: auth.info&.last_name
}
redirect_to link_oidc_account_path
end
end
def failure
# Sanitize reason to known values only
known_reasons = %w[sso_provider_unavailable sso_invalid_response sso_failed]
sanitized_reason = known_reasons.include?(params[:message]) ? params[:message] : "sso_failed"
# Log failed SSO attempt
SsoAuditLog.log_login_failed!(
provider: params[:strategy],
request: request,
reason: sanitized_reason
)
# Mobile SSO: redirect back to the app with error instead of web login page
if session[:mobile_sso].present?
session.delete(:mobile_sso)
mobile_sso_redirect(error: sanitized_reason, message: "SSO authentication failed")
return
end
# Desktop SSO: send the error back to the app via the custom scheme so it
# stops waiting, instead of stranding the flow on the web login page.
if session[:desktop_sso].present?
session.delete(:desktop_sso)
redirect_to "sure://sso/callback?error=#{sanitized_reason}", allow_other_host: true
return
end
message = case sanitized_reason
when "sso_provider_unavailable"
t("sessions.failure.sso_provider_unavailable")
when "sso_invalid_response"
t("sessions.failure.sso_invalid_response")
else
t("sessions.failure.sso_failed")
end
redirect_to new_session_path, alert: message
end
private
def handle_mobile_sso_callback(user)
device_info = session.delete(:mobile_sso)
unless device_info.present?
mobile_sso_redirect(error: "missing_session", message: "Mobile SSO session expired")
return
end
device = MobileDevice.upsert_device!(user, device_info.symbolize_keys)
token_response = device.issue_token!
# Store tokens behind a one-time authorization code instead of passing in URL
authorization_code = SecureRandom.urlsafe_base64(32)
Rails.cache.write(
"mobile_sso:#{authorization_code}",
token_response.merge(
user_id: user.id,
user_email: user.email,
user_first_name: user.first_name,
user_last_name: user.last_name,
user_ui_layout: user.ui_layout,
user_ai_enabled: user.ai_enabled?
),
expires_in: 5.minutes
)
mobile_sso_redirect(code: authorization_code)
rescue ActiveRecord::RecordInvalid => e
Rails.logger.warn("[Mobile SSO] Device save failed: #{e.record.errors.full_messages.join(', ')}")
mobile_sso_redirect(error: "device_error", message: "Unable to register device")
end
def handle_desktop_sso_callback(user)
context = (session.delete(:desktop_sso) || {}).with_indifferent_access
code_challenge = context[:code_challenge]
if code_challenge.blank?
redirect_to "sure://sso/callback?error=missing_session", allow_other_host: true
return
end
# One-time authorization code, bound to the PKCE challenge, exchanged by
# the desktop webview for a session. Short TTL + single-use + PKCE.
code = SecureRandom.urlsafe_base64(32)
Rails.cache.write(
"desktop_sso:#{code}",
{ "user_id" => user.id, "code_challenge" => code_challenge },
expires_in: 2.minutes
)
redirect_to "sure://sso/callback?code=#{code}", allow_other_host: true
end
def handle_mobile_sso_onboarding(auth)
device_info = session.delete(:mobile_sso)
email = auth.info&.email
has_pending_invitation = email.present? && Invitation.pending.exists?(email: email)
allow_creation = has_pending_invitation || (!AuthConfig.jit_link_only? && AuthConfig.allowed_oidc_domain?(email))
linking_code = SecureRandom.urlsafe_base64(32)
Rails.cache.write(
"mobile_sso_link:#{linking_code}",
{
provider: auth.provider,
uid: auth.uid,
email: email,
first_name: auth.info&.first_name,
last_name: auth.info&.last_name,
name: auth.info&.name,
issuer: auth.extra&.raw_info&.iss || auth.extra&.raw_info&.[]("iss"),
device_info: device_info,
allow_account_creation: allow_creation
},
expires_in: 10.minutes
)
mobile_sso_redirect(
status: "account_not_linked",
linking_code: linking_code,
email: email,
first_name: auth.info&.first_name,
last_name: auth.info&.last_name,
allow_account_creation: allow_creation,
has_pending_invitation: has_pending_invitation
)
end
def mobile_sso_redirect(params = {})
redirect_to "sureapp://oauth/callback?#{params.to_query}", allow_other_host: true
end
def set_session
@session = Current.user.sessions.find(params[:id])
end
def log_super_admin_override_login(user)
# Only log when local login is globally disabled but an emergency
# super-admin override is enabled.
return if AuthConfig.local_login_enabled?
return unless AuthConfig.local_admin_override_enabled?
return unless user&.super_admin?
Rails.logger.info("[AUTH] Super admin override login: user_id=#{user.id} email=#{user.email}")
end
def demo_host_match?(demo)
return false unless demo.present? && demo["hosts"].present?
demo["hosts"].include?(request.host)
end
def build_idp_logout_url(oidc_identity, id_token)
# Find the provider configuration using unified loader (supports both YAML and DB providers)
provider_config = ProviderLoader.load_providers.find do |p|
p[:name] == oidc_identity.provider
end
return nil unless provider_config
# For OIDC providers, fetch end_session_endpoint from discovery
if provider_config[:strategy] == "openid_connect" && provider_config[:issuer].present?
begin
discovery_url = discovery_url_for(provider_config[:issuer])
response = Faraday.new(ssl: self.class.faraday_ssl_options).get(discovery_url) do |req|
req.options.timeout = 5
req.options.open_timeout = 3
end
return nil unless response.success?
discovery = JSON.parse(response.body)
end_session_endpoint = discovery["end_session_endpoint"]
return nil unless end_session_endpoint.present?
# Build the logout URL with post_logout_redirect_uri
post_logout_redirect = "#{request.base_url}/auth/logout/callback"
params = {
id_token_hint: id_token,
post_logout_redirect_uri: post_logout_redirect
}
"#{end_session_endpoint}?#{params.to_query}"
rescue Faraday::Error, JSON::ParserError, StandardError => e
Rails.logger.warn("[SSO] Failed to fetch OIDC discovery for logout: #{e.message}")
nil
end
else
nil
end
end
def discovery_url_for(issuer)
if issuer.end_with?("/")
"#{issuer}.well-known/openid-configuration"
else
"#{issuer}/.well-known/openid-configuration"
end
end
end