Hotwire Native Authentication with TurboFailureApp
Table of Contents
- The Problem with Standard Devise Authentication
- TurboFailureApp Implementation
- Authentication Flow Architecture
- Mobile App Integration
- Configuration Setup
- Testing Strategies
- Troubleshooting Guide
The Problem
Standard Web Authentication vs Mobile App Requirements
Web Browser Behavior:
- Unauthenticated requests receive 302 Found redirects to login page
- Browsers automatically follow redirects and display the login form
- CSRF tokens and session cookies work seamlessly
Mobile App (Hotwire Native) Requirements:
- Apps need explicit HTTP status codes to determine authentication state
- 401 Unauthorized signals "authentication required"
- 422 Unprocessable Entity indicates "authentication failed"
- Redirects can cause navigation confusion in native app contexts
Default Devise Behavior
# Standard Devise FailureApp behavior class Devise::FailureApp def respond if http_auth? http_auth else redirect # Returns 302 redirect - problematic for mobile apps end end end
Problems with Standard Approach:
- Mobile apps receive unexpected redirects instead of clear error codes
- Authentication state becomes ambiguous
- Native navigation can break when following web-based redirects
- API endpoints return HTML instead of JSON error responses
TurboFailureApp Implementation
Core Implementation
# config/initializers/devise.rb class TurboFailureApp < Devise::FailureApp # Compatibility for Turbo::Native::Navigation class << self def helper_method(*methods) end end include Turbo::Native::Navigation # Intercept for Hotwire Native: # Return a 401 for any :authenticate_user before actions # Return a 422 for any login failures # # This param is set in a before_action on Devise controllers to ensure they don't return 401s def http_auth? (hotwire_native_app? && !params["hotwire_native_form"]) || super end end
Key Method Breakdown
http_auth? Method
Purpose: Determines whether to return HTTP status codes vs redirects
Logic Flow:
- Hotwire Native Detection: Check if request comes from mobile app
- Form Parameter Check: Look for hotwire_native_form parameter
- Fallback: Use standard Devise behavior for web requests
def http_auth? # Simplified logic: Return HTTP auth for native apps unless it's a form submission (hotwire_native_app? && !params["hotwire_native_form"]) || super end
Key Changes from Standard Implementation:
- Uses concise boolean logic instead of conditional blocks
- Relies on Turbo::Native::Navigation's built-in hotwire_native_app? detection
- No custom Android handling (uses Turbo Native's default behavior)
Authentication Flow Architecture
Request Flow Diagram
Mobile App Request ↓ Authentication Required? ↓ ↓ YES NO ↓ ↓ TurboFailureApp Continue ↓ ↓ hotwire_native_app? Response ↓ ↓ YES NO ↓ ↓ Return 401 Return 302 (HTTP Auth) (Redirect)
Response Code Mapping
ScenarioWeb ResponseMobile ResponsePurposeNot authenticated | 302 → /login | 401 Unauthorized | Prompt login
Login failed | 302 → /login | 422 Unprocessable Entity | Show error
Invalid credentials | 302 → /login | 422 Unprocessable Entity | Validation failed
Session expired | 302 → /login | 401 Unauthorized | Re-authenticate
Form submission | 302 → /login | 302 → /login | Allow normal flow
Login failed | 302 → /login | 422 Unprocessable Entity | Show error
Invalid credentials | 302 → /login | 422 Unprocessable Entity | Validation failed
Session expired | 302 → /login | 401 Unauthorized | Re-authenticate
Form submission | 302 → /login | 302 → /login | Allow normal flow
Controller Integration
App Namespace Protection
# app/controllers/app_controller.rb class AppController < ApplicationController before_action :authenticate_user! # Triggers TurboFailureApp on failure layout "app" respond_to :html, :json end
Custom Sessions Controller
# app/controllers/users/sessions_controller.rb class Users::SessionsController < Devise::SessionsController skip_before_action :verify_authenticity_token, only: [:new, :create] respond_to :html, :json def create respond_to do |format| format.html { super } format.json do self.resource = warden.authenticate(auth_options) if resource resource.remember_me = true sign_in(resource_name, resource) render json: { message: "User signed in successfully.", user: { id: resource.id, email: resource.email }, redirect_url: after_sign_in_path_for(resource) }, status: :ok else render json: { error: "Invalid email or password" }, status: :unauthorized end end end end def failure respond_to do |format| format.html { super } format.json do render json: { error: "Invalid email or password" }, status: :unauthorized end end end end
Mobile App Integration
iOS Hotwire Native Implementation
Authentication State Detection
// iOS Native Code func handleResponse(_ response: HTTPURLResponse) { switch response.statusCode { case 401: // User needs to authenticate presentLoginScreen() case 422: // Authentication failed, show error showAuthenticationError() case 200...299: // Success, continue normal flow handleSuccessResponse() default: // Handle other status codes handleError(response.statusCode) } }
Turbo Session Configuration
// Configure Turbo session to handle authentication session.delegate = self extension ViewController: SessionDelegate { func session(_ session: Session, didFailRequestForVisitable visitable: Visitable, error: Error) { if let turboError = error as? TurboError, case let .http(statusCode) = turboError { switch statusCode { case 401: presentAuthenticationController() case 422: showValidationErrors() default: showErrorMessage(for: statusCode) } } } }
Form Submission Handling
Hotwire Native Form Parameter
<!-- app/views/devise/sessions/new.html.erb --> <%= form_with(model: resource, as: resource_name, url: session_path(resource_name), local: false, data: { turbo: true }) do |f| %> <!-- Add hidden field for native apps --> <%= hidden_field_tag :hotwire_native_form, "true" if hotwire_native_app? %> <div class="field"> <%= f.label :email %> <%= f.email_field :email, autofocus: true, autocomplete: "email" %> </div> <div class="field"> <%= f.label :password %> <%= f.password_field :password, autocomplete: "current-password" %> </div> <div class="actions"> <%= f.submit "Log in" %> </div> <% end %>
Helper Method for Detection
# app/helpers/application_helper.rb module ApplicationHelper def hotwire_native_app? request.user_agent&.include?("Turbo Native") end end
Configuration Setup
Complete Devise Configuration
# config/initializers/devise.rb Devise.setup do |config| # Configure mailer config.mailer_sender = "Your App <hello@yourapp.com>" # Configure navigational formats to include JSON config.navigational_formats = ["*/*", :html, :turbo_stream, :json] # Hotwire/Turbo configuration config.responder.error_status = :unprocessable_entity config.responder.redirect_status = :see_other # Configure Warden to use TurboFailureApp config.warden do |manager| manager.failure_app = TurboFailureApp end # OAuth configuration config.omniauth :google_oauth2, ENV['GOOGLE_CLIENT_ID'], ENV['GOOGLE_CLIENT_SECRET'] config.omniauth :apple, ENV['APPLE_CLIENT_ID'], "", { scope: "email name", team_id: ENV['APPLE_TEAM_ID'], key_id: ENV['APPLE_KEY_ID'], pem: ENV['APPLE_PEM'] } end
Route Configuration
# config/routes.rb Rails.application.routes.draw do devise_for :users, controllers: { registrations: "users/registrations", omniauth_callbacks: "users/omniauth_callbacks", sessions: "users/sessions" } # Custom auth endpoints for mobile apps devise_scope :user do post "users/apple_auth", to: "users/apple_auth#authenticate_apple_user" post "users/google_auth", to: "users/google_auth#authenticate_google_user" end # Protected app routes namespace :app do resources :dashboard, only: [:index] # ... other protected routes end end
Environment-Specific Settings
# config/environments/development.rb Rails.application.configure do # Allow insecure HTTP for development mobile testing config.force_ssl = false # Enable detailed logging for authentication debugging config.log_level = :debug end # config/environments/production.rb Rails.application.configure do # Ensure secure connections for production config.force_ssl = true # Configure proper CORS for mobile apps config.middleware.insert_before 0, Rack::Cors do allow do origins '*' resource '*', headers: :any, methods: [:get, :post, :put, :patch, :delete, :options, :head], expose: ['X-CSRF-Token'] end end end
Testing Strategies
RSpec Integration Tests
# spec/requests/authentication_spec.rb RSpec.describe "Authentication", type: :request do describe "TurboFailureApp integration" do context "when request comes from Hotwire Native iOS app" do let(:headers) { { "User-Agent" => "Turbo Native iOS" } } it "returns 401 for unauthenticated requests" do get "/app/dashboard", headers: headers expect(response).to have_http_status(:unauthorized) expect(response.content_type).to include("application/json") end end context "when request comes from web browser" do let(:headers) { { "User-Agent" => "Mozilla/5.0" } } it "redirects to login page" do get "/app/dashboard", headers: headers expect(response).to have_http_status(:found) expect(response).to redirect_to(new_user_session_path) end end context "when request comes from Android" do let(:headers) { { "User-Agent" => "Turbo Native Android" } } it "uses Turbo Native default behavior" do get "/app/dashboard", headers: headers # Behavior depends on Turbo::Native::Navigation implementation expect(response).to have_http_status(:unauthorized).or have_http_status(:found) end end end describe "Login failures" do context "with invalid credentials from mobile app" do let(:headers) { { "User-Agent" => "Turbo Native iOS" } } it "returns 422 with JSON error" do post "/users/sign_in", params: { user: { email: "test@example.com", password: "wrong" } }, headers: headers.merge({ "Accept" => "application/json" }) expect(response).to have_http_status(:unprocessable_entity) expect(JSON.parse(response.body)).to include("error") end end end end
Feature Testing with User Agents
# spec/features/mobile_authentication_spec.rb RSpec.describe "Mobile Authentication", type: :feature, js: true do before do page.driver.header("User-Agent", "Turbo Native iOS") end scenario "unauthenticated mobile user receives proper status codes" do visit "/app/dashboard" # Expect proper status code handling in mobile context expect(page.status_code).to eq(401) end end
Manual Testing Checklist
# Test with curl commands # 1. Test unauthenticated request from iOS curl -H "User-Agent: Turbo Native iOS" \ -v http://localhost:3000/app/dashboard # Expected: HTTP/1.1 401 Unauthorized # 2. Test unauthenticated request from web curl -H "User-Agent: Mozilla/5.0" \ -v http://localhost:3000/app/dashboard # Expected: HTTP/1.1 302 Found (redirect) # 3. Test failed login from mobile curl -X POST \ -H "User-Agent: Turbo Native iOS" \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -d '{"user":{"email":"test@example.com","password":"wrong"}}' \ -v http://localhost:3000/users/sign_in # Expected: HTTP/1.1 422 Unprocessable Entity
Troubleshooting Guide
Common Issues and Solutions
Issue: Mobile App Still Receives Redirects
Symptoms: 302 responses instead of 401/422 Diagnosis:
# Add debugging to TurboFailureApp def http_auth? Rails.logger.info "User Agent: #{request.user_agent}" Rails.logger.info "Hotwire Native?: #{hotwire_native_app?}" Rails.logger.info "Params: #{params.inspect}" result = if hotwire_native_app? return true unless params["hotwire_native_form"] else super end Rails.logger.info "HTTP Auth Result: #{result}" result end
Solutions:
- Verify User-Agent string detection matches Turbo::Native::Navigation expectations
- Check if Turbo::Native::Navigation is properly included
- Ensure mobile app sends correct User-Agent header
Issue: Form Submissions Return Wrong Status Codes
Symptoms: Forms in mobile app return 401 instead of processing Diagnosis: hotwire_native_form parameter missing or incorrect
Solution:
<!-- Ensure parameter is included in forms --> <%= form_with(model: resource, local: false) do |f| %> <%= hidden_field_tag :hotwire_native_form, "true" if hotwire_native_app? %> <!-- rest of form --> <% end %>
Issue: JSON Responses Not Returned
Symptoms: HTML returned instead of JSON for mobile requests Solutions:
- Add Accept: application/json header in mobile requests
- Configure controllers to respond to JSON:
class ApplicationController < ActionController::Base respond_to :html, :json end
Issue: CSRF Token Errors
Symptoms: ActionController::InvalidAuthenticityToken errors Solutions:
# Skip CSRF for API endpoints class Users::SessionsController < Devise::SessionsController skip_before_action :verify_authenticity_token, only: [:create] end
Debugging Tools
Request Logger
# config/application.rb class Application < Rails::Application if Rails.env.development? config.middleware.use Class.new do def initialize(app) @app = app end def call(env) request = ActionDispatch::Request.new(env) Rails.logger.info "=== REQUEST DEBUG ===" Rails.logger.info "User-Agent: #{request.user_agent}" Rails.logger.info "Accept: #{request.headers['Accept']}" Rails.logger.info "Path: #{request.path}" Rails.logger.info "Method: #{request.method}" status, headers, response = @app.call(env) Rails.logger.info "Response Status: #{status}" Rails.logger.info "====================" [status, headers, response] end end end end
Performance Considerations
Caching User Agent Detection
class TurboFailureApp < Devise::FailureApp # Note: hotwire_native_app? comes from Turbo::Native::Navigation # No need to override unless you have specific platform requirements end
Minimal Overhead
- User-Agent string parsing is fast
- No database queries required
- Caching prevents repeated string operations
This implementation provides a robust foundation for handling authentication in Hotwire Native applications while maintaining backward compatibility with web browsers. The key insight is distinguishing between authentication challenges (401) and authentication failures (422), allowing mobile apps to respond appropriately to each scenario.