mirror of
https://github.com/we-promise/sure.git
synced 2026-07-27 20:22:16 +00:00
* 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>
754 lines
25 KiB
Ruby
754 lines
25 KiB
Ruby
require "test_helper"
|
|
|
|
class SessionsControllerTest < ActionDispatch::IntegrationTest
|
|
setup do
|
|
@user = users(:family_admin)
|
|
|
|
# Ensure the shared OAuth application exists
|
|
Doorkeeper::Application.find_or_create_by!(name: "Sure Mobile") do |app|
|
|
app.redirect_uri = "sureapp://oauth/callback"
|
|
app.scopes = "read_write"
|
|
app.confidential = false
|
|
end
|
|
|
|
# Clear the memoized class variable so it picks up the test record
|
|
MobileDevice.instance_variable_set(:@shared_oauth_application, nil)
|
|
end
|
|
|
|
teardown do
|
|
# Clear OmniAuth mock auth after each test
|
|
OmniAuth.config.mock_auth[:openid_connect] = nil
|
|
end
|
|
|
|
def setup_omniauth_mock(provider:, uid:, email:, name:, first_name: nil, last_name: nil)
|
|
OmniAuth.config.mock_auth[:openid_connect] = OmniAuth::AuthHash.new({
|
|
provider: provider,
|
|
uid: uid,
|
|
info: {
|
|
email: email,
|
|
name: name,
|
|
first_name: first_name,
|
|
last_name: last_name
|
|
}.compact
|
|
})
|
|
end
|
|
|
|
test "login page" do
|
|
get new_session_url
|
|
assert_response :success
|
|
end
|
|
|
|
test "can sign in" do
|
|
sign_in @user
|
|
assert_redirected_to root_url
|
|
assert Session.exists?(user_id: @user.id)
|
|
|
|
get root_url
|
|
assert_response :success
|
|
end
|
|
|
|
test "fails to sign in with bad password" do
|
|
post sessions_url, params: { email: @user.email, password: "bad" }
|
|
assert_response :unprocessable_entity
|
|
assert_equal "Invalid email or password.", flash[:alert]
|
|
end
|
|
|
|
test "redirects when local login is disabled" do
|
|
AuthConfig.stubs(:local_login_enabled?).returns(false)
|
|
AuthConfig.stubs(:local_admin_override_enabled?).returns(false)
|
|
|
|
post sessions_url, params: { email: @user.email, password: user_password_test }
|
|
|
|
assert_redirected_to new_session_path
|
|
assert_equal "Local password login is disabled. Please use single sign-on.", flash[:alert]
|
|
end
|
|
|
|
test "allows super admin local login when override enabled" do
|
|
super_admin = users(:sure_support_staff)
|
|
|
|
AuthConfig.stubs(:local_login_enabled?).returns(false)
|
|
AuthConfig.stubs(:local_admin_override_enabled?).returns(true)
|
|
|
|
post sessions_url, params: { email: super_admin.email, password: user_password_test }
|
|
|
|
assert_redirected_to root_path
|
|
assert Session.exists?(user_id: super_admin.id)
|
|
end
|
|
|
|
test "shows invalid credentials for super admin when override enabled but password is wrong" do
|
|
super_admin = users(:sure_support_staff)
|
|
|
|
AuthConfig.stubs(:local_login_enabled?).returns(false)
|
|
AuthConfig.stubs(:local_admin_override_enabled?).returns(true)
|
|
|
|
post sessions_url, params: { email: super_admin.email, password: "bad" }
|
|
|
|
assert_response :unprocessable_entity
|
|
assert_equal "Invalid email or password.", flash[:alert]
|
|
end
|
|
|
|
test "blocks non-super-admin local login when override enabled" do
|
|
AuthConfig.stubs(:local_login_enabled?).returns(false)
|
|
AuthConfig.stubs(:local_admin_override_enabled?).returns(true)
|
|
|
|
post sessions_url, params: { email: @user.email, password: user_password_test }
|
|
|
|
assert_redirected_to new_session_path
|
|
assert_equal "Local password login is disabled. Please use single sign-on.", flash[:alert]
|
|
end
|
|
|
|
test "renders multiple SSO provider buttons" do
|
|
AuthConfig.stubs(:local_login_form_visible?).returns(true)
|
|
AuthConfig.stubs(:password_features_enabled?).returns(true)
|
|
AuthConfig.stubs(:sso_providers).returns([
|
|
{ id: "oidc", strategy: "openid_connect", name: "openid_connect", label: "Sign in with Keycloak", icon: "key" },
|
|
{ id: "google", strategy: "google_oauth2", name: "google_oauth2", label: "Sign in with Google", icon: "google" }
|
|
])
|
|
|
|
get new_session_path
|
|
assert_response :success
|
|
|
|
# Generic OIDC button
|
|
assert_match %r{/auth/openid_connect}, @response.body
|
|
assert_match /Sign in with Keycloak/, @response.body
|
|
|
|
# Google-branded button — DS outline button carrying Google's official
|
|
# multi-color "G" mark (one of its brand hexes proves the inline SVG rendered).
|
|
assert_match %r{/auth/google_oauth2}, @response.body
|
|
assert_match /#4285F4/i, @response.body
|
|
assert_match /Sign in with Google/, @response.body
|
|
end
|
|
|
|
test "can sign out" do
|
|
sign_in @user
|
|
session_record = @user.sessions.last
|
|
|
|
delete session_url(session_record)
|
|
assert_redirected_to new_session_path
|
|
assert_equal "You have signed out successfully.", flash[:notice]
|
|
|
|
# Verify session is destroyed
|
|
assert_nil Session.find_by(id: session_record.id)
|
|
end
|
|
|
|
test "redirects to MFA verification when MFA enabled" do
|
|
@user.setup_mfa!
|
|
@user.enable_mfa!
|
|
@user.sessions.destroy_all # Clean up any existing sessions
|
|
|
|
post sessions_path, params: { email: @user.email, password: user_password_test }
|
|
|
|
assert_redirected_to verify_mfa_path
|
|
assert_equal @user.id, session[:mfa_user_id]
|
|
assert_not Session.exists?(user_id: @user.id)
|
|
end
|
|
|
|
# OIDC Authentication Tests
|
|
test "authenticates with existing OIDC identity" do
|
|
oidc_identity = oidc_identities(:bob_google)
|
|
|
|
# Set up OmniAuth mock
|
|
setup_omniauth_mock(
|
|
provider: oidc_identity.provider,
|
|
uid: oidc_identity.uid,
|
|
email: @user.email,
|
|
name: "Bob Dylan",
|
|
first_name: "Bob",
|
|
last_name: "Dylan"
|
|
)
|
|
|
|
get "/auth/openid_connect/callback"
|
|
|
|
assert_redirected_to root_path
|
|
assert Session.exists?(user_id: @user.id)
|
|
end
|
|
|
|
test "redirects to MFA when user has MFA and uses OIDC" do
|
|
@user.setup_mfa!
|
|
@user.enable_mfa!
|
|
@user.sessions.destroy_all
|
|
oidc_identity = oidc_identities(:bob_google)
|
|
|
|
# Set up OmniAuth mock
|
|
setup_omniauth_mock(
|
|
provider: oidc_identity.provider,
|
|
uid: oidc_identity.uid,
|
|
email: @user.email,
|
|
name: "Bob Dylan"
|
|
)
|
|
|
|
get "/auth/openid_connect/callback"
|
|
|
|
assert_redirected_to verify_mfa_path
|
|
assert_equal @user.id, session[:mfa_user_id]
|
|
assert_not Session.exists?(user_id: @user.id)
|
|
end
|
|
|
|
test "redirects to account linking when no OIDC identity exists" do
|
|
# Use an existing user's email who doesn't have OIDC linked yet
|
|
user_without_oidc = users(:new_email)
|
|
|
|
# Set up OmniAuth mock
|
|
setup_omniauth_mock(
|
|
provider: "openid_connect",
|
|
uid: "new-uid-99999",
|
|
email: user_without_oidc.email,
|
|
name: "New User"
|
|
)
|
|
|
|
get "/auth/openid_connect/callback"
|
|
|
|
assert_redirected_to link_oidc_account_path
|
|
|
|
# Follow redirect to verify session data is accessible
|
|
follow_redirect!
|
|
assert_response :success
|
|
|
|
# Verify the session has the pending auth data by checking page content
|
|
assert_select "p", text: /To link your openid_connect account/
|
|
end
|
|
|
|
test "handles missing auth data gracefully" do
|
|
# Set up mock with invalid/incomplete auth to simulate failure
|
|
OmniAuth.config.mock_auth[:openid_connect] = OmniAuth::AuthHash.new({
|
|
provider: nil,
|
|
uid: nil
|
|
})
|
|
|
|
get "/auth/openid_connect/callback"
|
|
|
|
assert_redirected_to new_session_path
|
|
assert_equal "Could not authenticate via OpenID Connect.", flash[:alert]
|
|
end
|
|
|
|
# ── Mobile SSO: mobile_sso_start ──
|
|
|
|
test "mobile_sso_start renders auto-submit form for valid provider" do
|
|
Rails.configuration.x.auth.stubs(:sso_providers).returns([
|
|
{ name: "google_oauth2", strategy: "google_oauth2", label: "Google" }
|
|
])
|
|
|
|
get "/auth/mobile/google_oauth2", params: {
|
|
device_id: "test-device-123",
|
|
device_name: "Pixel 8",
|
|
device_type: "android",
|
|
os_version: "14",
|
|
app_version: "1.0.0"
|
|
}
|
|
|
|
assert_response :success
|
|
assert_match %r{action="/auth/google_oauth2"}, @response.body
|
|
assert_match %r{method="post"}, @response.body
|
|
assert_match /authenticity_token/, @response.body
|
|
end
|
|
|
|
test "mobile_sso_start stores device info in session" do
|
|
Rails.configuration.x.auth.stubs(:sso_providers).returns([
|
|
{ name: "google_oauth2", strategy: "google_oauth2", label: "Google" }
|
|
])
|
|
|
|
get "/auth/mobile/google_oauth2", params: {
|
|
device_id: "test-device-123",
|
|
device_name: "Pixel 8",
|
|
device_type: "android",
|
|
os_version: "14",
|
|
app_version: "1.0.0"
|
|
}
|
|
|
|
assert_equal "test-device-123", session[:mobile_sso][:device_id]
|
|
assert_equal "Pixel 8", session[:mobile_sso][:device_name]
|
|
assert_equal "android", session[:mobile_sso][:device_type]
|
|
assert_equal "14", session[:mobile_sso][:os_version]
|
|
assert_equal "1.0.0", session[:mobile_sso][:app_version]
|
|
end
|
|
|
|
test "mobile_sso_start redirects with error for invalid provider" do
|
|
Rails.configuration.x.auth.stubs(:sso_providers).returns([
|
|
{ name: "google_oauth2", strategy: "google_oauth2", label: "Google" }
|
|
])
|
|
|
|
get "/auth/mobile/unknown_provider", params: {
|
|
device_id: "test-device-123",
|
|
device_name: "Pixel 8",
|
|
device_type: "android"
|
|
}
|
|
|
|
assert_redirected_to %r{\Asureapp://oauth/callback\?error=invalid_provider}
|
|
end
|
|
|
|
test "mobile_sso_start redirects with error when device_id is missing" do
|
|
Rails.configuration.x.auth.stubs(:sso_providers).returns([
|
|
{ name: "google_oauth2", strategy: "google_oauth2", label: "Google" }
|
|
])
|
|
|
|
get "/auth/mobile/google_oauth2", params: {
|
|
device_name: "Pixel 8",
|
|
device_type: "android"
|
|
}
|
|
|
|
assert_redirected_to %r{\Asureapp://oauth/callback\?error=missing_device_info}
|
|
end
|
|
|
|
test "mobile_sso_start redirects with error when device_name is missing" do
|
|
Rails.configuration.x.auth.stubs(:sso_providers).returns([
|
|
{ name: "google_oauth2", strategy: "google_oauth2", label: "Google" }
|
|
])
|
|
|
|
get "/auth/mobile/google_oauth2", params: {
|
|
device_id: "test-device-123",
|
|
device_type: "android"
|
|
}
|
|
|
|
assert_redirected_to %r{\Asureapp://oauth/callback\?error=missing_device_info}
|
|
end
|
|
|
|
test "mobile_sso_start redirects with error when device_type is missing" do
|
|
Rails.configuration.x.auth.stubs(:sso_providers).returns([
|
|
{ name: "google_oauth2", strategy: "google_oauth2", label: "Google" }
|
|
])
|
|
|
|
get "/auth/mobile/google_oauth2", params: {
|
|
device_id: "test-device-123",
|
|
device_name: "Pixel 8"
|
|
}
|
|
|
|
assert_redirected_to %r{\Asureapp://oauth/callback\?error=missing_device_info}
|
|
end
|
|
|
|
# ── Mobile SSO: openid_connect callback with mobile_sso session ──
|
|
|
|
test "mobile SSO issues Doorkeeper tokens for linked user" do
|
|
# Test environment uses null_store; swap in a memory store so the
|
|
# authorization code round-trip (write in controller, read in sso_exchange) works.
|
|
original_cache = Rails.cache
|
|
Rails.cache = ActiveSupport::Cache::MemoryStore.new
|
|
|
|
oidc_identity = oidc_identities(:bob_google)
|
|
|
|
setup_omniauth_mock(
|
|
provider: oidc_identity.provider,
|
|
uid: oidc_identity.uid,
|
|
email: @user.email,
|
|
name: "Bob Dylan",
|
|
first_name: "Bob",
|
|
last_name: "Dylan"
|
|
)
|
|
|
|
# Simulate mobile_sso session data (would be set by mobile_sso_start)
|
|
post sessions_path, params: { email: @user.email, password: user_password_test }
|
|
delete session_url(@user.sessions.last)
|
|
|
|
# We need to set the session directly via a custom approach:
|
|
# Hit mobile_sso_start first, then trigger the OIDC callback
|
|
Rails.configuration.x.auth.stubs(:sso_providers).returns([
|
|
{ name: "openid_connect", strategy: "openid_connect", label: "Google" }
|
|
])
|
|
|
|
get "/auth/mobile/openid_connect", params: {
|
|
device_id: "flutter-device-001",
|
|
device_name: "Pixel 8",
|
|
device_type: "android",
|
|
os_version: "14",
|
|
app_version: "1.0.0"
|
|
}
|
|
|
|
assert_response :success
|
|
|
|
# Now trigger the OIDC callback — session[:mobile_sso] is set from the previous request
|
|
get "/auth/openid_connect/callback"
|
|
|
|
assert_response :redirect
|
|
redirect_url = @response.redirect_url
|
|
|
|
assert redirect_url.start_with?("sureapp://oauth/callback?"), "Expected redirect to sureapp:// but got #{redirect_url}"
|
|
|
|
uri = URI.parse(redirect_url)
|
|
callback_params = Rack::Utils.parse_query(uri.query)
|
|
|
|
assert callback_params["code"].present?, "Expected authorization code in callback"
|
|
|
|
# Exchange the authorization code for tokens via the API (as the mobile app would)
|
|
post "/api/v1/auth/sso_exchange", params: { code: callback_params["code"] }, as: :json
|
|
|
|
assert_response :success
|
|
token_data = JSON.parse(@response.body)
|
|
|
|
assert token_data["access_token"].present?, "Expected access_token in response"
|
|
assert token_data["refresh_token"].present?, "Expected refresh_token in response"
|
|
assert_equal "Bearer", token_data["token_type"]
|
|
assert_equal 30.days.to_i, token_data["expires_in"]
|
|
assert_equal @user.id, token_data["user"]["id"]
|
|
assert_equal @user.email, token_data["user"]["email"]
|
|
assert_equal @user.first_name, token_data["user"]["first_name"]
|
|
assert_equal @user.last_name, token_data["user"]["last_name"]
|
|
ensure
|
|
Rails.cache = original_cache
|
|
end
|
|
|
|
test "mobile SSO creates a MobileDevice record" do
|
|
oidc_identity = oidc_identities(:bob_google)
|
|
|
|
setup_omniauth_mock(
|
|
provider: oidc_identity.provider,
|
|
uid: oidc_identity.uid,
|
|
email: @user.email,
|
|
name: "Bob Dylan"
|
|
)
|
|
|
|
Rails.configuration.x.auth.stubs(:sso_providers).returns([
|
|
{ name: "openid_connect", strategy: "openid_connect", label: "Google" }
|
|
])
|
|
|
|
get "/auth/mobile/openid_connect", params: {
|
|
device_id: "flutter-device-002",
|
|
device_name: "iPhone 15",
|
|
device_type: "ios",
|
|
os_version: "17.2",
|
|
app_version: "1.0.0"
|
|
}
|
|
|
|
assert_difference "MobileDevice.count", 1 do
|
|
get "/auth/openid_connect/callback"
|
|
end
|
|
|
|
device = @user.mobile_devices.find_by(device_id: "flutter-device-002")
|
|
assert device.present?, "Expected MobileDevice to be created"
|
|
assert_equal "iPhone 15", device.device_name
|
|
assert_equal "ios", device.device_type
|
|
assert_equal "17.2", device.os_version
|
|
assert_equal "1.0.0", device.app_version
|
|
end
|
|
|
|
test "mobile SSO uses the shared OAuth application" do
|
|
oidc_identity = oidc_identities(:bob_google)
|
|
|
|
setup_omniauth_mock(
|
|
provider: oidc_identity.provider,
|
|
uid: oidc_identity.uid,
|
|
email: @user.email,
|
|
name: "Bob Dylan"
|
|
)
|
|
|
|
Rails.configuration.x.auth.stubs(:sso_providers).returns([
|
|
{ name: "openid_connect", strategy: "openid_connect", label: "Google" }
|
|
])
|
|
|
|
get "/auth/mobile/openid_connect", params: {
|
|
device_id: "flutter-device-003",
|
|
device_name: "Pixel 8",
|
|
device_type: "android"
|
|
}
|
|
|
|
assert_no_difference "Doorkeeper::Application.count" do
|
|
get "/auth/openid_connect/callback"
|
|
end
|
|
|
|
device = @user.mobile_devices.find_by(device_id: "flutter-device-003")
|
|
assert device.active_tokens.any?, "Expected device to have active tokens via shared app"
|
|
end
|
|
|
|
test "mobile SSO revokes previous tokens for existing device" do
|
|
oidc_identity = oidc_identities(:bob_google)
|
|
|
|
setup_omniauth_mock(
|
|
provider: oidc_identity.provider,
|
|
uid: oidc_identity.uid,
|
|
email: @user.email,
|
|
name: "Bob Dylan"
|
|
)
|
|
|
|
Rails.configuration.x.auth.stubs(:sso_providers).returns([
|
|
{ name: "openid_connect", strategy: "openid_connect", label: "Google" }
|
|
])
|
|
|
|
# First login — creates device and token
|
|
get "/auth/mobile/openid_connect", params: {
|
|
device_id: "flutter-device-004",
|
|
device_name: "Pixel 8",
|
|
device_type: "android"
|
|
}
|
|
get "/auth/openid_connect/callback"
|
|
|
|
device = @user.mobile_devices.find_by(device_id: "flutter-device-004")
|
|
first_token = Doorkeeper::AccessToken.where(
|
|
mobile_device_id: device.id,
|
|
resource_owner_id: @user.id,
|
|
revoked_at: nil
|
|
).last
|
|
|
|
assert first_token.present?, "Expected first access token"
|
|
|
|
# Second login with same device — should revoke old token
|
|
setup_omniauth_mock(
|
|
provider: oidc_identity.provider,
|
|
uid: oidc_identity.uid,
|
|
email: @user.email,
|
|
name: "Bob Dylan"
|
|
)
|
|
|
|
get "/auth/mobile/openid_connect", params: {
|
|
device_id: "flutter-device-004",
|
|
device_name: "Pixel 8",
|
|
device_type: "android"
|
|
}
|
|
get "/auth/openid_connect/callback"
|
|
|
|
first_token.reload
|
|
assert first_token.revoked_at.present?, "Expected first token to be revoked"
|
|
end
|
|
|
|
test "mobile SSO redirects MFA user with error" do
|
|
@user.setup_mfa!
|
|
@user.enable_mfa!
|
|
|
|
oidc_identity = oidc_identities(:bob_google)
|
|
|
|
setup_omniauth_mock(
|
|
provider: oidc_identity.provider,
|
|
uid: oidc_identity.uid,
|
|
email: @user.email,
|
|
name: "Bob Dylan"
|
|
)
|
|
|
|
Rails.configuration.x.auth.stubs(:sso_providers).returns([
|
|
{ name: "openid_connect", strategy: "openid_connect", label: "Google" }
|
|
])
|
|
|
|
get "/auth/mobile/openid_connect", params: {
|
|
device_id: "flutter-device-005",
|
|
device_name: "Pixel 8",
|
|
device_type: "android"
|
|
}
|
|
get "/auth/openid_connect/callback"
|
|
|
|
assert_response :redirect
|
|
redirect_url = @response.redirect_url
|
|
|
|
assert redirect_url.start_with?("sureapp://oauth/callback?"), "Expected redirect to sureapp://"
|
|
params = Rack::Utils.parse_query(URI.parse(redirect_url).query)
|
|
assert_equal "mfa_not_supported", params["error"]
|
|
assert_nil session[:mobile_sso], "Expected mobile_sso session to be cleared"
|
|
end
|
|
|
|
test "mobile SSO redirects with error when OIDC identity not linked" do
|
|
user_without_oidc = users(:new_email)
|
|
|
|
setup_omniauth_mock(
|
|
provider: "openid_connect",
|
|
uid: "unlinked-uid-99999",
|
|
email: user_without_oidc.email,
|
|
name: "New User"
|
|
)
|
|
|
|
Rails.configuration.x.auth.stubs(:sso_providers).returns([
|
|
{ name: "openid_connect", strategy: "openid_connect", label: "Google" }
|
|
])
|
|
|
|
# Use a real cache store so we can verify the cache entry written by handle_mobile_sso_onboarding
|
|
original_cache = Rails.cache
|
|
Rails.cache = ActiveSupport::Cache::MemoryStore.new
|
|
|
|
begin
|
|
get "/auth/mobile/openid_connect", params: {
|
|
device_id: "flutter-device-006",
|
|
device_name: "Pixel 8",
|
|
device_type: "android"
|
|
}
|
|
get "/auth/openid_connect/callback"
|
|
|
|
assert_response :redirect
|
|
redirect_url = @response.redirect_url
|
|
|
|
assert redirect_url.start_with?("sureapp://oauth/callback?"), "Expected redirect to sureapp://"
|
|
params = Rack::Utils.parse_query(URI.parse(redirect_url).query)
|
|
assert_equal "account_not_linked", params["status"]
|
|
assert params["linking_code"].present?, "Expected linking_code in redirect params"
|
|
assert_nil session[:mobile_sso], "Expected mobile_sso session to be cleared"
|
|
|
|
# Verify the cache entry written by handle_mobile_sso_onboarding
|
|
cached = Rails.cache.read("mobile_sso_link:#{params['linking_code']}")
|
|
assert cached.present?, "Expected cache entry for mobile_sso_link:#{params['linking_code']}"
|
|
assert_equal "openid_connect", cached[:provider]
|
|
assert_equal "unlinked-uid-99999", cached[:uid]
|
|
assert_equal user_without_oidc.email, cached[:email]
|
|
assert_equal "New User", cached[:name]
|
|
assert cached.key?(:device_info), "Expected device_info in cached payload"
|
|
assert cached.key?(:allow_account_creation), "Expected allow_account_creation in cached payload"
|
|
ensure
|
|
Rails.cache = original_cache
|
|
end
|
|
end
|
|
|
|
test "mobile SSO does not create a web session" do
|
|
oidc_identity = oidc_identities(:bob_google)
|
|
|
|
setup_omniauth_mock(
|
|
provider: oidc_identity.provider,
|
|
uid: oidc_identity.uid,
|
|
email: @user.email,
|
|
name: "Bob Dylan"
|
|
)
|
|
|
|
Rails.configuration.x.auth.stubs(:sso_providers).returns([
|
|
{ name: "openid_connect", strategy: "openid_connect", label: "Google" }
|
|
])
|
|
|
|
@user.sessions.destroy_all
|
|
|
|
get "/auth/mobile/openid_connect", params: {
|
|
device_id: "flutter-device-007",
|
|
device_name: "Pixel 8",
|
|
device_type: "android"
|
|
}
|
|
|
|
assert_no_difference "Session.count" do
|
|
get "/auth/openid_connect/callback"
|
|
end
|
|
end
|
|
|
|
# ── Mobile SSO: failure action ──
|
|
|
|
test "failure redirects mobile SSO to app with error" do
|
|
# Simulate mobile_sso session being set
|
|
Rails.configuration.x.auth.stubs(:sso_providers).returns([
|
|
{ name: "google_oauth2", strategy: "google_oauth2", label: "Google" }
|
|
])
|
|
|
|
get "/auth/mobile/google_oauth2", params: {
|
|
device_id: "flutter-device-008",
|
|
device_name: "Pixel 8",
|
|
device_type: "android"
|
|
}
|
|
|
|
# Now simulate a failure callback
|
|
get "/auth/failure", params: { message: "sso_failed", strategy: "google_oauth2" }
|
|
|
|
assert_response :redirect
|
|
redirect_url = @response.redirect_url
|
|
assert redirect_url.start_with?("sureapp://oauth/callback?"), "Expected redirect to sureapp://"
|
|
params = Rack::Utils.parse_query(URI.parse(redirect_url).query)
|
|
assert_equal "sso_failed", params["error"]
|
|
assert_nil session[:mobile_sso], "Expected mobile_sso session to be cleared"
|
|
end
|
|
|
|
test "failure without mobile SSO session redirects to web login" do
|
|
get "/auth/failure", params: { message: "sso_failed", strategy: "google_oauth2" }
|
|
|
|
assert_redirected_to new_session_path
|
|
end
|
|
|
|
test "failure sanitizes unknown error reasons" do
|
|
Rails.configuration.x.auth.stubs(:sso_providers).returns([
|
|
{ name: "google_oauth2", strategy: "google_oauth2", label: "Google" }
|
|
])
|
|
|
|
get "/auth/mobile/google_oauth2", params: {
|
|
device_id: "flutter-device-009",
|
|
device_name: "Pixel 8",
|
|
device_type: "android"
|
|
}
|
|
|
|
get "/auth/failure", params: { message: "xss_attempt<script>", strategy: "google_oauth2" }
|
|
|
|
redirect_url = @response.redirect_url
|
|
params = Rack::Utils.parse_query(URI.parse(redirect_url).query)
|
|
assert_equal "sso_failed", params["error"], "Unknown reason should be sanitized to sso_failed"
|
|
end
|
|
|
|
test "prevents account takeover via email matching" do
|
|
# Clean up any existing sessions
|
|
@user.sessions.destroy_all
|
|
|
|
# This test verifies that we can't authenticate just by matching email
|
|
# The user must have an existing OIDC identity with matching provider + uid
|
|
# Set up OmniAuth mock
|
|
setup_omniauth_mock(
|
|
provider: "openid_connect",
|
|
uid: "attacker-uid-12345", # Different UID than user's OIDC identity
|
|
email: @user.email, # Same email as existing user
|
|
name: "Attacker"
|
|
)
|
|
|
|
get "/auth/openid_connect/callback"
|
|
|
|
# Should NOT create a session, should redirect to account linking
|
|
assert_redirected_to link_oidc_account_path
|
|
assert_not Session.exists?(user_id: @user.id), "Session should not be created for unlinked OIDC identity"
|
|
|
|
# Follow redirect to verify we're on the link page (not logged in)
|
|
follow_redirect!
|
|
assert_response :success
|
|
end
|
|
|
|
# ── Desktop SSO: browser handoff + PKCE code exchange ──
|
|
|
|
test "desktop SSO exchanges a PKCE-bound code for a web session and is single-use" do
|
|
original_cache = Rails.cache
|
|
Rails.cache = ActiveSupport::Cache::MemoryStore.new
|
|
|
|
verifier = SecureRandom.hex(32)
|
|
challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false)
|
|
oidc_identity = oidc_identities(:bob_google)
|
|
|
|
Rails.configuration.x.auth.stubs(:sso_providers).returns([
|
|
{ name: "openid_connect", strategy: "openid_connect", label: "Google" }
|
|
])
|
|
setup_omniauth_mock(provider: oidc_identity.provider, uid: oidc_identity.uid, email: @user.email, name: "Bob Dylan")
|
|
|
|
get "/auth/desktop/openid_connect", params: { code_challenge: challenge }
|
|
assert_response :success
|
|
|
|
get "/auth/openid_connect/callback"
|
|
assert_response :redirect
|
|
redirect_url = @response.redirect_url
|
|
assert redirect_url.start_with?("sure://sso/callback?code="), "Expected sure://sso/callback but got #{redirect_url}"
|
|
code = Rack::Utils.parse_query(URI.parse(redirect_url).query)["code"]
|
|
assert code.present?
|
|
|
|
assert_difference -> { oidc_identity.user.sessions.count }, 1 do
|
|
post desktop_sso_exchange_path, params: { code: code, code_verifier: verifier }
|
|
end
|
|
assert_redirected_to root_path
|
|
|
|
# Single-use: the same code cannot be redeemed again.
|
|
assert_no_difference -> { oidc_identity.user.sessions.count } do
|
|
post desktop_sso_exchange_path, params: { code: code, code_verifier: verifier }
|
|
end
|
|
assert_redirected_to new_session_path
|
|
ensure
|
|
Rails.cache = original_cache
|
|
end
|
|
|
|
test "desktop SSO exchange rejects a wrong PKCE verifier" do
|
|
original_cache = Rails.cache
|
|
Rails.cache = ActiveSupport::Cache::MemoryStore.new
|
|
|
|
challenge = Base64.urlsafe_encode64(Digest::SHA256.digest("the-real-verifier"), padding: false)
|
|
oidc_identity = oidc_identities(:bob_google)
|
|
|
|
Rails.configuration.x.auth.stubs(:sso_providers).returns([
|
|
{ name: "openid_connect", strategy: "openid_connect", label: "Google" }
|
|
])
|
|
setup_omniauth_mock(provider: oidc_identity.provider, uid: oidc_identity.uid, email: @user.email, name: "Bob Dylan")
|
|
|
|
get "/auth/desktop/openid_connect", params: { code_challenge: challenge }
|
|
get "/auth/openid_connect/callback"
|
|
code = Rack::Utils.parse_query(URI.parse(@response.redirect_url).query)["code"]
|
|
|
|
assert_no_difference -> { oidc_identity.user.sessions.count } do
|
|
post desktop_sso_exchange_path, params: { code: code, code_verifier: "an-attacker-guess" }
|
|
end
|
|
assert_redirected_to new_session_path
|
|
ensure
|
|
Rails.cache = original_cache
|
|
end
|
|
|
|
test "desktop_sso_start rejects a missing PKCE code_challenge" do
|
|
Rails.configuration.x.auth.stubs(:sso_providers).returns([
|
|
{ name: "openid_connect", strategy: "openid_connect", label: "Google" }
|
|
])
|
|
get "/auth/desktop/openid_connect"
|
|
assert_redirected_to new_session_path
|
|
end
|
|
end
|