SSO Authentication Flow#
Sure's SSO authentication is built on OmniAuth with the openid_connect strategy (plus google_oauth2, github, and saml). The flow runs through SessionsController as the primary orchestrator, with OidcAccountsController handling identity linking and JIT account creation. Three distinct client paths (web, mobile, desktop) all converge at the same OmniAuth callback.
Sign-In Initiation#
All three entry points render an auto-submitting HTML form that POSTs to the OmniAuth endpoint — required by omniauth-rails_csrf_protection.
| Client | Entry point | Session state stashed |
|---|---|---|
| Web | Login page SSO button | — |
| Mobile | GET /auth/mobile/:provider → sessions#mobile_sso_start | session[:mobile_sso] with device metadata |
| Desktop | GET /auth/desktop/:provider → sessions#desktop_sso_start | session[:desktop_sso] with PKCE code_challenge |
For the desktop flow, the app opens a system browser (so passkeys work) and requires a well-formed 43-char base64url SHA-256 challenge before stashing it . Both mobile and desktop reuse the same auto-submitting mobile_sso_start view template .
Provider availability is validated against Rails.configuration.x.auth.sso_providers — the list populated by the OmniAuth initializer at boot .
OIDC Discovery & OmniAuth Strategy Setup#
The OmniAuth initializer (config/initializers/omniauth.rb) loads providers at boot via ProviderLoader.load_providers — either from config/auth.yml or the SsoProvider database model, controlled by FeatureFlags.db_sso_providers? with a 5-minute cache.
For openid_connect providers, Oidc::ProviderOptionsBuilder constructs the OmniAuth options:
- PKCE + discovery enabled by default —
discovery: true, pkce: true - Default scopes:
openid email profile - Groups claim requested via OIDC Core
claimsparameter to support role mapping - Falls back to environment variables
OIDC_ISSUER,OIDC_CLIENT_ID,OIDC_CLIENT_SECRET,OIDC_REDIRECT_URI
Discovery fetches the IdP's /.well-known/openid-configuration. If discovery or authentication fails, the OmniAuthErrorHandler middleware catches OpenIDConnect::Discovery::DiscoveryFailed and OmniAuth::Error and redirects to sessions#failure, which logs the event via SsoAuditLog and surfaces a user-facing error .
Callback Handling#
GET/POST /auth/:provider/callback routes to sessions#openid_connect. OmniAuth populates request.env["omniauth.auth"] with provider, uid, info, and credentials.
Identity lookup is by (provider, uid) — not email — to prevent account takeover via email manipulation .
Existing Identity (Happy Path)#
OidcIdentity#record_authentication!— updateslast_authenticated_atOidcIdentity#sync_user_attributes!— refreshes stored name/email/groups from IdP; syncs name to theUserrecord only if the user has no name set (preserving manual edits)OidcIdentity#apply_role_mapping!— maps IdP group claims to Sure roles (guest,member,admin,super_admin) per provider-configuredrole_mappingSsoAuditLog.log_login!fires- Flow diverges by client: web → session, mobile → token, desktop → code (see Session Creation)
No Identity (Linking Required)#
The auth hash (provider, uid, email, name) is stashed in session[:pending_oidc_auth] and the user is redirected to link_oidc_account_path . The mobile flow instead caches a linking_code and redirects back to the app .
Identity Linking & JIT Account Creation#
OidcAccountsController handles the two paths when no identity exists:
Link to existing account (create_link): The user submits their Sure password. After User.authenticate_by succeeds, OidcIdentity.create_from_omniauth creates the link record (stores provider, uid, issuer, info), SsoAuditLog.log_link! fires, and a session is created .
JIT account creation (create_user): Gated by two conditions :
- A pending
Invitationexists for the OIDC email, or AuthConfig.jit_link_only?is false and the email domain is inAuthConfig.allowed_oidc_domains
JIT users are created without password_digest to prevent chained attacks where an SSO user gains local login access via password reset . The user and OidcIdentity are created in a single transaction; family assignment uses the invitation's family or a new Family . On success, SsoAuditLog.log_jit_account_created! fires .
Session Creation#
The final step differs by client:
Web: create_session_for(user) creates a Session record and writes a permanent httponly signed session_token cookie. If MFA is enabled, session[:mfa_user_id] is set and the user is redirected to verify_mfa_path first .
Mobile: handle_mobile_sso_callback upserts a MobileDevice record, calls device.issue_token! to obtain Doorkeeper tokens, then stores the full token response behind a single-use authorization_code in Rails cache (5-minute TTL). The app receives sureapp://oauth/callback?code=<code> and redeems the code for tokens. MFA blocks the mobile SSO flow entirely .
Desktop: handle_desktop_sso_callback stores { user_id, code_challenge } in cache (2-minute TTL) and redirects to sure://sso/callback?code=<code>. The desktop app's webview then POSTs to sessions#desktop_exchange with code + code_verifier. The server:
- Atomically claims (deletes) the cache entry to prevent replay
- Verifies
SHA256(code_verifier) == code_challengevia constant-time compare - Creates a normal web session (MFA supported at this stage)
Federated Logout & Audit Logging#
At login time, session[:id_token_hint] and session[:sso_login_provider] are stored . On sign-out, sessions#destroy checks for these values and calls build_idp_logout_url, which fetches the end_session_endpoint from the IdP's OIDC discovery document at runtime (5-second timeout). If found, the user is redirected to the IdP for federated logout; the IdP then redirects back to sessions#post_logout. If discovery fails or the endpoint is absent, a plain local logout is performed.
SsoAuditLog records every significant SSO event:
| Event | Method |
|---|---|
| Successful login | log_login! |
| Failed login | log_login_failed! |
| Local logout | log_logout! |
| Federated (IdP) logout | log_logout_idp! |
| Identity link | log_link! |
| Identity unlink | log_unlink! |
| JIT account created | log_jit_account_created! |
Each record captures user, provider, IP address, and user agent .
Key Files#
| File | Role |
|---|---|
app/controllers/sessions_controller.rb | Main SSO orchestrator: initiation, callback, logout, mobile/desktop paths |
app/controllers/oidc_accounts_controller.rb | Identity linking and JIT account creation |
app/models/oidc_identity.rb | Identity record; attribute sync, role mapping, issuer validation |
config/initializers/omniauth.rb | OmniAuth strategy registration; loads providers via ProviderLoader |
app/models/oidc/provider_options_builder.rb | Builds OIDC OmniAuth options (PKCE, discovery, scopes, groups claim) |
app/services/provider_loader.rb | Loads provider config from YAML or DB (5-min cache) |
app/models/sso_audit_log.rb | Audit log for all SSO events |
app/middleware/omniauth_error_handler.rb | Catches discovery/auth errors; redirects to sessions#failure |
app/models/auth_config.rb | JIT mode flags, allowed OIDC domains, provider list |