mirror of
https://github.com/we-promise/sure.git
synced 2026-04-19 12:04:08 +00:00
Add Google Sign-In (SSO) support to Flutter mobile app (#860)
* Add mobile SSO support to sessions controller
Add /auth/mobile/:provider route and mobile_sso_start action that
captures device params in session and renders an auto-submitting POST
form to OmniAuth (required by omniauth-rails_csrf_protection).
Modify openid_connect callback to detect mobile_sso session, issue
Doorkeeper tokens via MobileDevice, and redirect to sureapp://oauth/callback
with tokens. Handles MFA users and unlinked accounts with error redirects.
Validates provider name against configured SSO providers and device info
before proceeding.
* Add SSO auth flow to Flutter service and provider
Add buildSsoUrl() and handleSsoCallback() to AuthService for
constructing the mobile SSO URL and parsing tokens from the deep
link callback.
Add startSsoLogin() and handleSsoCallback() to AuthProvider for
launching browser-based SSO and processing the redirect.
* Register deep link listener for SSO callback
Listen for sureapp://oauth/* deep links via app_links package,
handling both cold start (getInitialLink) and warm (uriLinkStream)
scenarios. Routes callbacks to AuthProvider.handleSsoCallback().
* Add Google Sign-In button to Flutter login screen
Add "or" divider and outlined Google Sign-In button that triggers
browser-based SSO via startSsoLogin('google_oauth2').
Add app_links and url_launcher dependencies to pubspec.yaml.
* Fix mobile SSO failure handling to redirect back to app
When OmniAuth fails during mobile SSO flow, redirect to
sureapp://oauth/callback with the error instead of the web login page.
Cleans up mobile_sso session data on failure.
* Address PR review feedback for mobile SSO flow
- Use strong params for device info in mobile_sso_start
- Guard against nil session data in handle_mobile_sso_callback
- Add error handling for AppLinks initialization and stream
- Handle launchUrl false return value in SSO login
- Use user-friendly error messages instead of exposing exceptions
- Reject empty token strings in SSO callback validation
* Consolidate mobile device token logic into MobileDevice model
Extract duplicated device upsert and token issuance code from
AuthController and SessionsController into MobileDevice. Add
CALLBACK_URL constant and URL builder helpers to eliminate repeated
deep-link strings. Add mobile SSO integration tests covering the
full flow, MFA rejection, unlinked accounts, and failure handling.
* Fix CI: resolve Brakeman redirect warnings and rubocop empty line
Move mobile SSO redirect into a private controller method with an
inline string literal so Brakeman can statically verify the target.
Remove unused URL builder helpers from MobileDevice. Fix extra empty
line at end of AuthController class body.
* Use authorization code exchange for mobile SSO and add signup error handling
Replace passing plaintext tokens in mobile SSO redirect URLs with a
one-time authorization code pattern. Tokens are now stored server-side
in Rails.cache (5min TTL) and exchanged via a secure POST to
/api/v1/auth/sso_exchange. Also wraps device/token creation in the
signup action with error handling and sanitizes device error messages.
* Add error handling for login device registration and blank SSO code guard
* Address PR #860 review: fix SSO race condition, add OpenAPI spec, and cleanup
- Fix race condition in sso_exchange by checking Rails.cache.delete return
value to ensure only one request can consume an authorization code
- Use strong parameters (params.require) for sso_exchange code param
- Move inline HTML from mobile_sso_start to a proper view template
- Clear stale session[:mobile_sso] flag on web login paths to prevent
abandoned mobile flows from hijacking subsequent web SSO logins
- Add OpenAPI/rswag spec for all auth API endpoints
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Fix mobile SSO test to match authorization code exchange pattern
The test was asserting tokens directly in the callback URL, but the code
uses an authorization code exchange pattern. Updated to exchange the code
via the sso_exchange API endpoint. Also swaps in a MemoryStore for this
test since the test environment uses null_store which discards writes.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Refactor mobile OAuth to use single shared application
Replace per-device Doorkeeper::Application creation with a shared
"Sure Mobile" OAuth app. Device tracking uses mobile_device_id on
access tokens instead of oauth_application_id on mobile_devices.
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -46,8 +46,13 @@ module Api
|
||||
InviteCode.claim!(params[:invite_code]) if params[:invite_code].present?
|
||||
|
||||
# Create device and OAuth token
|
||||
device = create_or_update_device(user)
|
||||
token_response = create_oauth_token_for_device(user, device)
|
||||
begin
|
||||
device = MobileDevice.upsert_device!(user, device_params)
|
||||
token_response = device.issue_token!
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
render json: { error: "Failed to register device: #{e.message}" }, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
render json: token_response.merge(
|
||||
user: {
|
||||
@@ -84,8 +89,13 @@ module Api
|
||||
end
|
||||
|
||||
# Create device and OAuth token
|
||||
device = create_or_update_device(user)
|
||||
token_response = create_oauth_token_for_device(user, device)
|
||||
begin
|
||||
device = MobileDevice.upsert_device!(user, device_params)
|
||||
token_response = device.issue_token!
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
render json: { error: "Failed to register device: #{e.message}" }, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
render json: token_response.merge(
|
||||
user: {
|
||||
@@ -100,6 +110,44 @@ module Api
|
||||
end
|
||||
end
|
||||
|
||||
def sso_exchange
|
||||
code = sso_exchange_params
|
||||
|
||||
if code.blank?
|
||||
render json: { error: "invalid_or_expired_code", message: "Authorization code is required" }, status: :unauthorized
|
||||
return
|
||||
end
|
||||
|
||||
cache_key = "mobile_sso:#{code}"
|
||||
cached = Rails.cache.read(cache_key)
|
||||
|
||||
unless cached.present?
|
||||
render json: { error: "invalid_or_expired_code", message: "Authorization code is invalid or expired" }, status: :unauthorized
|
||||
return
|
||||
end
|
||||
|
||||
# Atomic delete — only the request that successfully deletes the key may proceed.
|
||||
# This prevents a race where two concurrent requests both read the same code.
|
||||
unless Rails.cache.delete(cache_key)
|
||||
render json: { error: "invalid_or_expired_code", message: "Authorization code is invalid or expired" }, status: :unauthorized
|
||||
return
|
||||
end
|
||||
|
||||
render json: {
|
||||
access_token: cached[:access_token],
|
||||
refresh_token: cached[:refresh_token],
|
||||
token_type: cached[:token_type],
|
||||
expires_in: cached[:expires_in],
|
||||
created_at: cached[:created_at],
|
||||
user: {
|
||||
id: cached[:user_id],
|
||||
email: cached[:user_email],
|
||||
first_name: cached[:user_first_name],
|
||||
last_name: cached[:user_last_name]
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def refresh
|
||||
# Find the refresh token
|
||||
refresh_token = params[:refresh_token]
|
||||
@@ -121,6 +169,7 @@ module Api
|
||||
new_token = Doorkeeper::AccessToken.create!(
|
||||
application: access_token.application,
|
||||
resource_owner_id: access_token.resource_owner_id,
|
||||
mobile_device_id: access_token.mobile_device_id,
|
||||
expires_in: 30.days.to_i,
|
||||
scopes: access_token.scopes,
|
||||
use_refresh_token: true
|
||||
@@ -173,38 +222,12 @@ module Api
|
||||
required_fields.all? { |field| device[field].present? }
|
||||
end
|
||||
|
||||
def create_or_update_device(user)
|
||||
# Handle both string and symbol keys
|
||||
device_data = params[:device].permit(:device_id, :device_name, :device_type, :os_version, :app_version)
|
||||
|
||||
device = user.mobile_devices.find_or_initialize_by(device_id: device_data[:device_id])
|
||||
device.update!(device_data.merge(last_seen_at: Time.current))
|
||||
device
|
||||
def device_params
|
||||
params.require(:device).permit(:device_id, :device_name, :device_type, :os_version, :app_version)
|
||||
end
|
||||
|
||||
def create_oauth_token_for_device(user, device)
|
||||
# Create OAuth application for this device if needed
|
||||
oauth_app = device.create_oauth_application!
|
||||
|
||||
# Revoke any existing tokens for this device
|
||||
device.revoke_all_tokens!
|
||||
|
||||
# Create new access token with 30-day expiration
|
||||
access_token = Doorkeeper::AccessToken.create!(
|
||||
application: oauth_app,
|
||||
resource_owner_id: user.id,
|
||||
expires_in: 30.days.to_i,
|
||||
scopes: "read_write",
|
||||
use_refresh_token: true
|
||||
)
|
||||
|
||||
{
|
||||
access_token: access_token.plaintext_token,
|
||||
refresh_token: access_token.plaintext_refresh_token,
|
||||
token_type: "Bearer",
|
||||
expires_in: access_token.expires_in,
|
||||
created_at: access_token.created_at.to_i
|
||||
}
|
||||
def sso_exchange_params
|
||||
params.require(:code)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
class SessionsController < ApplicationController
|
||||
before_action :set_session, only: :destroy
|
||||
skip_authentication only: %i[index new create openid_connect failure post_logout]
|
||||
skip_authentication only: %i[index new create openid_connect failure post_logout mobile_sso_start]
|
||||
|
||||
layout "auth"
|
||||
|
||||
@@ -10,6 +10,9 @@ class SessionsController < ApplicationController
|
||||
end
|
||||
|
||||
def new
|
||||
# Clear any stale mobile SSO session flag from an abandoned mobile flow
|
||||
session.delete(:mobile_sso)
|
||||
|
||||
begin
|
||||
demo = Rails.application.config_for(:demo)
|
||||
@prefill_demo_credentials = demo_host_match?(demo)
|
||||
@@ -29,6 +32,9 @@ class SessionsController < ApplicationController
|
||||
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?
|
||||
@@ -104,6 +110,34 @@ class SessionsController < ApplicationController
|
||||
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
|
||||
|
||||
def openid_connect
|
||||
auth = request.env["omniauth.auth"]
|
||||
|
||||
@@ -122,13 +156,24 @@ class SessionsController < ApplicationController
|
||||
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
|
||||
|
||||
# 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
|
||||
|
||||
# Log successful SSO login
|
||||
SsoAuditLog.log_login!(user: user, provider: auth.provider, request: request)
|
||||
|
||||
# MFA check: If user has MFA enabled, require verification
|
||||
if user.otp_required?
|
||||
session[:mfa_user_id] = user.id
|
||||
@@ -138,6 +183,13 @@ class SessionsController < ApplicationController
|
||||
redirect_to root_path
|
||||
end
|
||||
else
|
||||
# Mobile SSO with no linked identity - redirect back with error
|
||||
if session[:mobile_sso].present?
|
||||
session.delete(:mobile_sso)
|
||||
mobile_sso_redirect(error: "account_not_linked", message: "Please link your Google account from the web app first")
|
||||
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] = {
|
||||
@@ -164,6 +216,13 @@ class SessionsController < ApplicationController
|
||||
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
|
||||
|
||||
message = case sanitized_reason
|
||||
when "sso_provider_unavailable"
|
||||
t("sessions.failure.sso_provider_unavailable")
|
||||
@@ -177,6 +236,40 @@ class SessionsController < ApplicationController
|
||||
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
|
||||
),
|
||||
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 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
|
||||
|
||||
@@ -7,7 +7,6 @@ class MobileDevice < ApplicationRecord
|
||||
end
|
||||
|
||||
belongs_to :user
|
||||
belongs_to :oauth_application, class_name: "Doorkeeper::Application", optional: true
|
||||
|
||||
validates :device_id, presence: true, uniqueness: { scope: :user_id }
|
||||
validates :device_name, presence: true
|
||||
@@ -15,8 +14,27 @@ class MobileDevice < ApplicationRecord
|
||||
|
||||
before_validation :set_last_seen_at, on: :create
|
||||
|
||||
CALLBACK_URL = "sureapp://oauth/callback"
|
||||
|
||||
scope :active, -> { where("last_seen_at > ?", 90.days.ago) }
|
||||
|
||||
def self.shared_oauth_application
|
||||
@shared_oauth_application ||= Doorkeeper::Application.find_by!(name: "Sure Mobile")
|
||||
end
|
||||
|
||||
def self.upsert_device!(user, attrs)
|
||||
device = user.mobile_devices.find_or_initialize_by(device_id: attrs[:device_id])
|
||||
device.assign_attributes(
|
||||
device_name: attrs[:device_name],
|
||||
device_type: attrs[:device_type],
|
||||
os_version: attrs[:os_version],
|
||||
app_version: attrs[:app_version],
|
||||
last_seen_at: Time.current
|
||||
)
|
||||
device.save!
|
||||
device
|
||||
end
|
||||
|
||||
def active?
|
||||
last_seen_at > 90.days.ago
|
||||
end
|
||||
@@ -25,26 +43,9 @@ class MobileDevice < ApplicationRecord
|
||||
update_column(:last_seen_at, Time.current)
|
||||
end
|
||||
|
||||
def create_oauth_application!
|
||||
return oauth_application if oauth_application.present?
|
||||
|
||||
app = Doorkeeper::Application.create!(
|
||||
name: "Mobile App - #{device_id}",
|
||||
redirect_uri: "sureapp://oauth/callback", # Custom scheme for mobile
|
||||
scopes: "read_write", # Use the configured scope
|
||||
confidential: false # Public client for mobile
|
||||
)
|
||||
|
||||
# Store the association
|
||||
update!(oauth_application: app)
|
||||
app
|
||||
end
|
||||
|
||||
def active_tokens
|
||||
return Doorkeeper::AccessToken.none unless oauth_application
|
||||
|
||||
Doorkeeper::AccessToken
|
||||
.where(application: oauth_application)
|
||||
.where(mobile_device_id: id)
|
||||
.where(resource_owner_id: user_id)
|
||||
.where(revoked_at: nil)
|
||||
.where("expires_in IS NULL OR created_at + expires_in * interval '1 second' > ?", Time.current)
|
||||
@@ -54,6 +55,30 @@ class MobileDevice < ApplicationRecord
|
||||
active_tokens.update_all(revoked_at: Time.current)
|
||||
end
|
||||
|
||||
# Issues a fresh Doorkeeper access token for this device, revoking any
|
||||
# previous tokens. Returns a hash with token details ready for an API
|
||||
# response or deep-link callback.
|
||||
def issue_token!
|
||||
revoke_all_tokens!
|
||||
|
||||
access_token = Doorkeeper::AccessToken.create!(
|
||||
application: self.class.shared_oauth_application,
|
||||
resource_owner_id: user_id,
|
||||
mobile_device_id: id,
|
||||
expires_in: 30.days.to_i,
|
||||
scopes: "read_write",
|
||||
use_refresh_token: true
|
||||
)
|
||||
|
||||
{
|
||||
access_token: access_token.plaintext_token,
|
||||
refresh_token: access_token.plaintext_refresh_token,
|
||||
token_type: "Bearer",
|
||||
expires_in: access_token.expires_in,
|
||||
created_at: access_token.created_at.to_i
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_last_seen_at
|
||||
|
||||
8
app/views/sessions/mobile_sso_start.html.erb
Normal file
8
app/views/sessions/mobile_sso_start.html.erb
Normal file
@@ -0,0 +1,8 @@
|
||||
<!DOCTYPE html>
|
||||
<html><body>
|
||||
<form id="sso_form" action="/auth/<%= ERB::Util.html_escape(@provider) %>" method="post">
|
||||
<input type="hidden" name="authenticity_token" value="<%= form_authenticity_token %>">
|
||||
</form>
|
||||
<script>document.getElementById('sso_form').submit();</script>
|
||||
<noscript><p>Redirecting to sign in... <a href="/auth/<%= ERB::Util.html_escape(@provider) %>">Click here</a> if not redirected.</p></noscript>
|
||||
</body></html>
|
||||
Reference in New Issue
Block a user