From c8eb5927467ce7e1123522481419bf88e8c0464f Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Thu, 4 Dec 2025 11:58:14 -0600 Subject: [PATCH 01/29] Allow JSON requests to send a magic link --- app/controllers/sessions_controller.rb | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index f5044bd2a..19239e494 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -1,7 +1,7 @@ class SessionsController < ApplicationController disallow_account_scope require_unauthenticated_access except: :destroy - rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." } + rate_limit to: 10, within: 3.minutes, only: :create, with: :rate_limit_exceeded layout "public" @@ -10,16 +10,21 @@ class SessionsController < ApplicationController def create if identity = Identity.find_by_email_address(email_address) - redirect_to_session_magic_link identity.send_magic_link + magic_link = identity.send_magic_link else signup = Signup.new(email_address: email_address) if signup.valid?(:identity_creation) magic_link = signup.create_identity if Account.accepting_signups? - redirect_to_session_magic_link magic_link else head :unprocessable_entity + return end end + + respond_to do |format| + format.html { redirect_to_session_magic_link magic_link } + format.json { head :created } + end end def destroy @@ -31,4 +36,12 @@ class SessionsController < ApplicationController def email_address params.expect(:email_address) end + + def rate_limit_exceeded + rate_limit_exceeded_message = "Try again later." + respond_to do |format| + format.html { redirect_to new_session_path, alert: rate_limit_exceeded_message } + format.json { render json: { message: rate_limit_exceeded_message }, status: :too_many_requests } + end + end end From 7644bb74113822266aac85a3d82a74170e45c13f Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Thu, 4 Dec 2025 12:14:23 -0600 Subject: [PATCH 02/29] Allow JSON requests submitting a magic link code --- .../sessions/magic_links_controller.rb | 58 ++++++++++++++----- 1 file changed, 45 insertions(+), 13 deletions(-) diff --git a/app/controllers/sessions/magic_links_controller.rb b/app/controllers/sessions/magic_links_controller.rb index c0632407a..958e8834a 100644 --- a/app/controllers/sessions/magic_links_controller.rb +++ b/app/controllers/sessions/magic_links_controller.rb @@ -1,7 +1,7 @@ class Sessions::MagicLinksController < ApplicationController disallow_account_scope require_unauthenticated_access - rate_limit to: 10, within: 15.minutes, only: :create, with: -> { redirect_to session_magic_link_path, alert: "Wait 15 minutes, then try again" } + rate_limit to: 10, within: 15.minutes, only: :create, with: :rate_limit_exceeded before_action :ensure_that_email_address_pending_authentication_exists layout "public" @@ -11,25 +11,20 @@ class Sessions::MagicLinksController < ApplicationController def create if magic_link = MagicLink.consume(code) - authenticate_with magic_link + respond_to_valid_code_from magic_link else - redirect_to session_magic_link_path, flash: { shake: true } + respond_to_invalid_code end end private def ensure_that_email_address_pending_authentication_exists unless email_address_pending_authentication.present? - redirect_to new_session_path, alert: "Enter your email address to sign in." - end - end - - def authenticate_with(magic_link) - if email_address_pending_authentication_matches?(magic_link.identity.email_address) - start_new_session_for magic_link.identity - redirect_to after_sign_in_url(magic_link) - else - redirect_to new_session_path, alert: "Authentication failed. Please try again." + alert_message = "Enter your email address to sign in." + respond_to do |format| + format.html { redirect_to new_session_path, alert: alert_message } + format.json { render json: { message: alert_message }, status: :unauthorized } + end end end @@ -37,6 +32,35 @@ class Sessions::MagicLinksController < ApplicationController params.expect(:code) end + def respond_to_valid_code_from(magic_link) + if email_address_pending_authentication_matches?(magic_link.identity.email_address) + respond_to do |format| + format.html do + start_new_session_for magic_link.identity + redirect_to after_sign_in_url(magic_link) + end + + format.json do + new_access_token = magic_link.identity.access_tokens.create!(permission: :write) + render json: { access_token: new_access_token.token } + end + end + else + alert_message = "Authentication failed. Please try again." + respond_to do |format| + format.html { redirect_to new_session_path, alert: alert_message } + format.json { render json: { message: alert_message }, status: :unauthorized } + end + end + end + + def respond_to_invalid_code + respond_to do |format| + format.html { redirect_to session_magic_link_path, flash: { shake: true } } + format.json { render json: { message: "Try another code." }, status: :unauthorized } + end + end + def after_sign_in_url(magic_link) if magic_link.for_sign_up? new_signup_completion_path @@ -44,4 +68,12 @@ class Sessions::MagicLinksController < ApplicationController after_authentication_url end end + + def rate_limit_exceeded + rate_limit_exceeded_message = "Try again in 15 minutes." + respond_to do |format| + format.html { redirect_to session_magic_link_path, alert: rate_limit_exceeded_message } + format.json { render json: { message: rate_limit_exceeded_message }, status: :too_many_requests } + end + end end From ab0f7a3ea540d7bc3ca0b9a7e3de19c434be7d16 Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Thu, 4 Dec 2025 14:11:43 -0600 Subject: [PATCH 03/29] Dev: Set magic link as header when JSON request in development --- app/controllers/sessions_controller.rb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 19239e494..e3079c36a 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -23,7 +23,10 @@ class SessionsController < ApplicationController respond_to do |format| format.html { redirect_to_session_magic_link magic_link } - format.json { head :created } + format.json do + response.set_header("X-Magic-Link-Code", magic_link&.code) if Rails.env.development? && magic_link + head :created + end end end From 093240c6f789e068724739ced97bab13899aa855 Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Thu, 4 Dec 2025 16:42:26 -0600 Subject: [PATCH 04/29] Return email and users too --- app/controllers/sessions/magic_links_controller.rb | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/controllers/sessions/magic_links_controller.rb b/app/controllers/sessions/magic_links_controller.rb index 958e8834a..d8a566573 100644 --- a/app/controllers/sessions/magic_links_controller.rb +++ b/app/controllers/sessions/magic_links_controller.rb @@ -42,7 +42,11 @@ class Sessions::MagicLinksController < ApplicationController format.json do new_access_token = magic_link.identity.access_tokens.create!(permission: :write) - render json: { access_token: new_access_token.token } + render json: { + email_address: magic_link.identity.email_address, + access_token: new_access_token.token, + users: magic_link.identity.users + } end end else From b92982b244256a018f5f1fee53b3239047205634 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Fri, 5 Dec 2025 11:49:29 +0100 Subject: [PATCH 05/29] Cleanup & simplify sign in --- app/controllers/concerns/authentication.rb | 5 +++-- app/controllers/sessions_controller.rb | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index deb8b3fa3..4054a3078 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -129,8 +129,9 @@ module Authentication end def serve_development_magic_link(magic_link) - if Rails.env.development? - flash[:magic_link_code] = magic_link&.code + if Rails.env.development? && magic_link.present? + flash[:magic_link_code] = magic_link.code + response.set_header("X-Magic-Link-Code", magic_link.code) end end end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index e3079c36a..877bc51dd 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -24,7 +24,6 @@ class SessionsController < ApplicationController respond_to do |format| format.html { redirect_to_session_magic_link magic_link } format.json do - response.set_header("X-Magic-Link-Code", magic_link&.code) if Rails.env.development? && magic_link head :created end end @@ -42,6 +41,7 @@ class SessionsController < ApplicationController def rate_limit_exceeded rate_limit_exceeded_message = "Try again later." + respond_to do |format| format.html { redirect_to new_session_path, alert: rate_limit_exceeded_message } format.json { render json: { message: rate_limit_exceeded_message }, status: :too_many_requests } From 54ceb4df7c0eff8f85d0b109257531b4ef664477 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Fri, 5 Dec 2025 12:15:35 +0100 Subject: [PATCH 06/29] Remove email address and users from magic link response The identity endpoint can be used to fetch that information --- app/controllers/sessions/magic_links_controller.rb | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/app/controllers/sessions/magic_links_controller.rb b/app/controllers/sessions/magic_links_controller.rb index d8a566573..958e8834a 100644 --- a/app/controllers/sessions/magic_links_controller.rb +++ b/app/controllers/sessions/magic_links_controller.rb @@ -42,11 +42,7 @@ class Sessions::MagicLinksController < ApplicationController format.json do new_access_token = magic_link.identity.access_tokens.create!(permission: :write) - render json: { - email_address: magic_link.identity.email_address, - access_token: new_access_token.token, - users: magic_link.identity.users - } + render json: { access_token: new_access_token.token } end end else From 58a92f0bb775f8546502324e513285089f98ebb3 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Fri, 5 Dec 2025 12:16:00 +0100 Subject: [PATCH 07/29] Add tests for sign in via API --- test/controllers/api_test.rb | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/test/controllers/api_test.rb b/test/controllers/api_test.rb index a48511cf8..d8ae510d5 100644 --- a/test/controllers/api_test.rb +++ b/test/controllers/api_test.rb @@ -6,6 +6,24 @@ class ApiTest < ActionDispatch::IntegrationTest @jasons_bearer_token = bearer_token_env(identity_access_tokens(:jasons_api_token).token) end + test "request a magic link" do + untenanted do + post session_path(format: :json), params: { email_address: identities(:david).email_address } + assert_response :created + end + end + + test "magic link consumption" do + identity = identities(:david) + magic_link = identity.send_magic_link + + untenanted do + post session_magic_link_path(format: :json), params: { code: magic_link.code } + assert_response :success + assert @response.parsed_body["access_token"].present? + end + end + test "authenticate with valid access token" do get boards_path(format: :json), env: @davids_bearer_token assert_response :success From c0a0786539b7aacc9f0cb4c91b4a8e689eaeb55e Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Fri, 5 Dec 2025 17:54:58 +0100 Subject: [PATCH 08/29] Return the session cookie We had a call about this. In short, we could reuse access tokens but then the user would see access tokens for every mobile device they have without any indication as to what is going on. So, since this really is just logging in instead of an integration which seems to be the primary purpose of access tokens, we can just use our regular session cookie for authentication. --- app/controllers/sessions/magic_links_controller.rb | 13 ++++--------- test/controllers/api_test.rb | 2 +- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/app/controllers/sessions/magic_links_controller.rb b/app/controllers/sessions/magic_links_controller.rb index 958e8834a..d83e7f807 100644 --- a/app/controllers/sessions/magic_links_controller.rb +++ b/app/controllers/sessions/magic_links_controller.rb @@ -34,16 +34,11 @@ class Sessions::MagicLinksController < ApplicationController def respond_to_valid_code_from(magic_link) if email_address_pending_authentication_matches?(magic_link.identity.email_address) - respond_to do |format| - format.html do - start_new_session_for magic_link.identity - redirect_to after_sign_in_url(magic_link) - end + start_new_session_for magic_link.identity - format.json do - new_access_token = magic_link.identity.access_tokens.create!(permission: :write) - render json: { access_token: new_access_token.token } - end + respond_to do |format| + format.html { redirect_to after_sign_in_url(magic_link) } + format.json { render json: { session_token: cookies[:session_token] } } end else alert_message = "Authentication failed. Please try again." diff --git a/test/controllers/api_test.rb b/test/controllers/api_test.rb index d8ae510d5..1830dc2a3 100644 --- a/test/controllers/api_test.rb +++ b/test/controllers/api_test.rb @@ -20,7 +20,7 @@ class ApiTest < ActionDispatch::IntegrationTest untenanted do post session_magic_link_path(format: :json), params: { code: magic_link.code } assert_response :success - assert @response.parsed_body["access_token"].present? + assert @response.parsed_body["session_token"].present? end end From e5bdb3b071d7f111dc5d14fa96a4c5f42e4725dc Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Thu, 11 Dec 2025 14:35:35 +0100 Subject: [PATCH 09/29] Add back missing development magic link prompt --- app/controllers/sessions_controller.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 877bc51dd..a111ef051 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -21,6 +21,8 @@ class SessionsController < ApplicationController end end + serve_development_magic_link magic_link + respond_to do |format| format.html { redirect_to_session_magic_link magic_link } format.json do From 360e14352fda326eefa07bea6675a58dfcfb6db7 Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Fri, 12 Dec 2025 11:39:41 -0600 Subject: [PATCH 10/29] Simplify session create logic for both html and json --- app/controllers/sessions_controller.rb | 39 +++++++++++++------------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index a111ef051..d4813d631 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -1,7 +1,7 @@ class SessionsController < ApplicationController disallow_account_scope require_unauthenticated_access except: :destroy - rate_limit to: 10, within: 3.minutes, only: :create, with: :rate_limit_exceeded + rate_limit to: 10, within: 3.minutes, only: :create, with: :rate_limit_exceeded layout "public" @@ -9,25 +9,15 @@ class SessionsController < ApplicationController end def create - if identity = Identity.find_by_email_address(email_address) - magic_link = identity.send_magic_link + if magic_link = magic_link_from_sign_in_or_sign_up + serve_development_magic_link magic_link + + respond_to do |format| + format.html { redirect_to_session_magic_link magic_link } + format.json { head :created } + end else - signup = Signup.new(email_address: email_address) - if signup.valid?(:identity_creation) - magic_link = signup.create_identity if Account.accepting_signups? - else - head :unprocessable_entity - return - end - end - - serve_development_magic_link magic_link - - respond_to do |format| - format.html { redirect_to_session_magic_link magic_link } - format.json do - head :created - end + head :unprocessable_entity end end @@ -37,6 +27,17 @@ class SessionsController < ApplicationController end private + def magic_link_from_sign_in_or_sign_up + if identity = Identity.find_by_email_address(email_address) + identity.send_magic_link + else + signup = Signup.new(email_address: email_address) + if signup.valid?(:identity_creation) + signup.create_identity if Account.accepting_signups? + end + end + end + def email_address params.expect(:email_address) end From a75a939289aa5805b69b2a3938bdd0c8693f001c Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Fri, 12 Dec 2025 11:47:09 -0600 Subject: [PATCH 11/29] Simplify code a bit --- app/controllers/sessions_controller.rb | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index d4813d631..a23fa69fc 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -32,9 +32,7 @@ class SessionsController < ApplicationController identity.send_magic_link else signup = Signup.new(email_address: email_address) - if signup.valid?(:identity_creation) - signup.create_identity if Account.accepting_signups? - end + signup.create_identity if signup.valid?(:identity_creation) && Account.accepting_signups? end end From 877f82c0cc3da8846671f14c5134df6889b8e0ce Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Fri, 12 Dec 2025 12:27:05 -0600 Subject: [PATCH 12/29] Pass a server token when creating a magic link via API --- app/controllers/concerns/authentication.rb | 14 +++++++++++++- app/controllers/sessions_controller.rb | 2 +- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index 4054a3078..dbea14ada 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -118,7 +118,19 @@ module Authentication end def email_address_pending_authentication - session[:email_address_pending_authentication] + if request.format.json? + verified_pending_authentication_token + else + session[:email_address_pending_authentication] + end + end + + def pending_authentication_token_for(email_address) + Rails.application.message_verifier(:pending_authentication).generate(email_address, expires_in: 10.minutes) + end + + def verified_pending_authentication_token + Rails.application.message_verifier(:pending_authentication).verified(params[:pending_authentication_token]) end def redirect_to_session_magic_link(magic_link, return_to: nil) diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index a23fa69fc..8d93d1dc2 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -14,7 +14,7 @@ class SessionsController < ApplicationController respond_to do |format| format.html { redirect_to_session_magic_link magic_link } - format.json { head :created } + format.json { render json: { pending_authentication_token: pending_authentication_token_for(email_address) }, status: :created } end else head :unprocessable_entity From a2333623b693b508b72f40bd4742a954f3fc0b33 Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Fri, 12 Dec 2025 12:45:00 -0600 Subject: [PATCH 13/29] Add unit tests for the new endpoints --- test/controllers/api_test.rb | 99 ++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/test/controllers/api_test.rb b/test/controllers/api_test.rb index 1830dc2a3..c4b1f433a 100644 --- a/test/controllers/api_test.rb +++ b/test/controllers/api_test.rb @@ -16,14 +16,85 @@ class ApiTest < ActionDispatch::IntegrationTest test "magic link consumption" do identity = identities(:david) magic_link = identity.send_magic_link + pending_token = pending_authentication_token_for(identity.email_address) + + untenanted do + post session_magic_link_path(format: :json), params: { code: magic_link.code, pending_authentication_token: pending_token } + assert_response :success + assert @response.parsed_body["session_token"].present? + end + end + + test "full JSON authentication flow with pending_authentication_token" do + identity = identities(:david) + + untenanted do + post session_path(format: :json), params: { email_address: identity.email_address } + assert_response :created + pending_token = @response.parsed_body["pending_authentication_token"] + assert pending_token.present? + + magic_link = MagicLink.last + post session_magic_link_path(format: :json), params: { code: magic_link.code, pending_authentication_token: pending_token } + assert_response :success + assert @response.parsed_body["session_token"].present? + end + end + + test "magic link consumption without pending_authentication_token returns unauthorized" do + identity = identities(:david) + magic_link = identity.send_magic_link untenanted do post session_magic_link_path(format: :json), params: { code: magic_link.code } + assert_response :unauthorized + assert_equal "Enter your email address to sign in.", @response.parsed_body["message"] + end + end + + test "magic link consumption with invalid code via JSON" do + identity = identities(:david) + pending_token = pending_authentication_token_for(identity.email_address) + + untenanted do + post session_magic_link_path(format: :json), params: { code: "INVALID", pending_authentication_token: pending_token } + assert_response :unauthorized + assert_equal "Try another code.", @response.parsed_body["message"] + end + end + + test "magic link consumption with cross-user code via JSON creates session for magic link owner" do + identity = identities(:david) + other_identity = identities(:jason) + magic_link = other_identity.send_magic_link + pending_token = pending_authentication_token_for(identity.email_address) + + # Note: Unlike the HTML flow, the JSON flow creates a session for the magic link's identity + # regardless of whose pending_authentication_token was provided. The token only proves + # that *someone* requested a magic link for *some* email address. + untenanted do + post session_magic_link_path(format: :json), params: { code: magic_link.code, pending_authentication_token: pending_token } assert_response :success assert @response.parsed_body["session_token"].present? end end + test "magic link consumption with expired pending_authentication_token" do + identity = identities(:david) + magic_link = identity.send_magic_link + + expired_token = nil + travel_to 15.minutes.ago do + expired_token = pending_authentication_token_for(identity.email_address) + end + + untenanted do + post session_magic_link_path(format: :json), params: { code: magic_link.code, pending_authentication_token: expired_token } + assert_response :unauthorized + assert_equal "Enter your email address to sign in.", @response.parsed_body["message"] + end + end + test "authenticate with valid access token" do get boards_path(format: :json), env: @davids_bearer_token assert_response :success @@ -42,8 +113,36 @@ class ApiTest < ActionDispatch::IntegrationTest assert_response :success end + test "create session for new user via JSON" do + new_email = "new-user-#{SecureRandom.hex(6)}@example.com" + + untenanted do + assert_difference -> { Identity.count }, 1 do + assert_difference -> { MagicLink.count }, 1 do + post session_path(format: :json), params: { email_address: new_email } + end + end + assert_response :created + assert @response.parsed_body["pending_authentication_token"].present? + assert MagicLink.last.for_sign_up? + end + end + + test "create session with invalid email via JSON" do + untenanted do + assert_no_difference -> { Identity.count } do + post session_path(format: :json), params: { email_address: "not-a-valid-email" } + end + assert_response :unprocessable_entity + end + end + private def bearer_token_env(token) { "HTTP_AUTHORIZATION" => "Bearer #{token}" } end + + def pending_authentication_token_for(email_address) + Rails.application.message_verifier(:pending_authentication).generate(email_address, expires_in: 10.minutes) + end end From 9009e0fb4961148a89f9317574fa59107b55950c Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Fri, 12 Dec 2025 12:47:20 -0600 Subject: [PATCH 14/29] Change test expectation on single tenant mode account creation --- test/controllers/sessions_controller_test.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/controllers/sessions_controller_test.rb b/test/controllers/sessions_controller_test.rb index 74b336a4f..78c7937e2 100644 --- a/test/controllers/sessions_controller_test.rb +++ b/test/controllers/sessions_controller_test.rb @@ -55,7 +55,7 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest end end - assert_redirected_to session_magic_link_path + assert_response :unprocessable_entity end end end From fbf829b346e362cd017ead93369f4d47006cd0c5 Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Fri, 12 Dec 2025 12:47:57 -0600 Subject: [PATCH 15/29] Update API test for cross code --- test/controllers/api_test.rb | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/test/controllers/api_test.rb b/test/controllers/api_test.rb index c4b1f433a..cc9ed7507 100644 --- a/test/controllers/api_test.rb +++ b/test/controllers/api_test.rb @@ -63,19 +63,16 @@ class ApiTest < ActionDispatch::IntegrationTest end end - test "magic link consumption with cross-user code via JSON creates session for magic link owner" do + test "magic link consumption with cross-user code via JSON returns unauthorized" do identity = identities(:david) other_identity = identities(:jason) magic_link = other_identity.send_magic_link pending_token = pending_authentication_token_for(identity.email_address) - # Note: Unlike the HTML flow, the JSON flow creates a session for the magic link's identity - # regardless of whose pending_authentication_token was provided. The token only proves - # that *someone* requested a magic link for *some* email address. untenanted do post session_magic_link_path(format: :json), params: { code: magic_link.code, pending_authentication_token: pending_token } - assert_response :success - assert @response.parsed_body["session_token"].present? + assert_response :unauthorized + assert_equal "Authentication failed. Please try again.", @response.parsed_body["message"] end end From 6e8d6a3df05de4255b4c7e72868168e3d09ba59c Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Fri, 12 Dec 2025 13:05:33 -0600 Subject: [PATCH 16/29] Update to always return a pending auth token for JSON responses. --- app/controllers/sessions_controller.rb | 5 ++++- test/controllers/api_test.rb | 5 +++-- test/controllers/sessions_controller_test.rb | 19 +++++++------------ 3 files changed, 14 insertions(+), 15 deletions(-) diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 8d93d1dc2..827f45f0b 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -17,7 +17,10 @@ class SessionsController < ApplicationController format.json { render json: { pending_authentication_token: pending_authentication_token_for(email_address) }, status: :created } end else - head :unprocessable_entity + respond_to do |format| + format.html { redirect_to session_magic_link_path } + format.json { render json: { pending_authentication_token: pending_authentication_token_for(email_address) }, status: :created } + end end end diff --git a/test/controllers/api_test.rb b/test/controllers/api_test.rb index cc9ed7507..053a14500 100644 --- a/test/controllers/api_test.rb +++ b/test/controllers/api_test.rb @@ -125,12 +125,13 @@ class ApiTest < ActionDispatch::IntegrationTest end end - test "create session with invalid email via JSON" do + test "create session with invalid email via JSON still returns token to prevent enumeration" do untenanted do assert_no_difference -> { Identity.count } do post session_path(format: :json), params: { email_address: "not-a-valid-email" } end - assert_response :unprocessable_entity + assert_response :created + assert @response.parsed_body["pending_authentication_token"].present? end end diff --git a/test/controllers/sessions_controller_test.rb b/test/controllers/sessions_controller_test.rb index 78c7937e2..31fb84dd6 100644 --- a/test/controllers/sessions_controller_test.rb +++ b/test/controllers/sessions_controller_test.rb @@ -55,23 +55,18 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest end end - assert_response :unprocessable_entity + assert_redirected_to session_magic_link_path end end end - test "create with invalid email address" do - # Avoid Sentry exceptions when attackers try to stuff invalid emails. The browser performs form - # field validation that should normally prevent this from occurring, so I'm not worried about - # returning proper validation errors. - without_action_dispatch_exception_handling do - untenanted do - assert_no_difference -> { Identity.count } do - post session_path, params: { email_address: "not-a-valid-email" } - end - - assert_response :unprocessable_entity + test "create with invalid email address still redirects to prevent enumeration" do + untenanted do + assert_no_difference -> { Identity.count } do + post session_path, params: { email_address: "not-a-valid-email" } end + + assert_redirected_to session_magic_link_path end end From b3c8d02709841f845f50b4e98b0869412b7efc2d Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Fri, 12 Dec 2025 13:10:43 -0600 Subject: [PATCH 17/29] Cleanup session creation --- app/controllers/sessions_controller.rb | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 827f45f0b..6513a42be 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -9,17 +9,20 @@ class SessionsController < ApplicationController end def create - if magic_link = magic_link_from_sign_in_or_sign_up - serve_development_magic_link magic_link + magic_link = magic_link_from_sign_in_or_sign_up + serve_development_magic_link magic_link - respond_to do |format| - format.html { redirect_to_session_magic_link magic_link } - format.json { render json: { pending_authentication_token: pending_authentication_token_for(email_address) }, status: :created } + respond_to do |format| + format.html do + if magic_link.present? + redirect_to_session_magic_link magic_link + else + redirect_to session_magic_link_path + end end - else - respond_to do |format| - format.html { redirect_to session_magic_link_path } - format.json { render json: { pending_authentication_token: pending_authentication_token_for(email_address) }, status: :created } + + format.json do + render json: { pending_authentication_token: pending_authentication_token_for(email_address) }, status: :created end end end From 6be20e215bba2fc5755f046895c43994c81ab0c1 Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Fri, 12 Dec 2025 13:20:40 -0600 Subject: [PATCH 18/29] Rename test to clarify what they're about --- test/controllers/{api_test.rb => api_authentication_test.rb} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename test/controllers/{api_test.rb => api_authentication_test.rb} (98%) diff --git a/test/controllers/api_test.rb b/test/controllers/api_authentication_test.rb similarity index 98% rename from test/controllers/api_test.rb rename to test/controllers/api_authentication_test.rb index 053a14500..b662c6087 100644 --- a/test/controllers/api_test.rb +++ b/test/controllers/api_authentication_test.rb @@ -1,6 +1,6 @@ require "test_helper" -class ApiTest < ActionDispatch::IntegrationTest +class ApiAuthenticationTest < ActionDispatch::IntegrationTest setup do @davids_bearer_token = bearer_token_env(identity_access_tokens(:davids_api_token).token) @jasons_bearer_token = bearer_token_env(identity_access_tokens(:jasons_api_token).token) From be447f90ba0f42113ec7071a001539fbf03e7621 Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Fri, 12 Dec 2025 13:25:21 -0600 Subject: [PATCH 19/29] Move magic link api tests to their own files --- test/controllers/api_test.rb | 31 +++++++++++++++++++ ...ication_test.rb => magic_link_api_test.rb} | 29 +---------------- 2 files changed, 32 insertions(+), 28 deletions(-) create mode 100644 test/controllers/api_test.rb rename test/controllers/{api_authentication_test.rb => magic_link_api_test.rb} (81%) diff --git a/test/controllers/api_test.rb b/test/controllers/api_test.rb new file mode 100644 index 000000000..a48511cf8 --- /dev/null +++ b/test/controllers/api_test.rb @@ -0,0 +1,31 @@ +require "test_helper" + +class ApiTest < ActionDispatch::IntegrationTest + setup do + @davids_bearer_token = bearer_token_env(identity_access_tokens(:davids_api_token).token) + @jasons_bearer_token = bearer_token_env(identity_access_tokens(:jasons_api_token).token) + end + + test "authenticate with valid access token" do + get boards_path(format: :json), env: @davids_bearer_token + assert_response :success + end + + test "fail to authenticate with invalid access token" do + get boards_path(format: :json), env: bearer_token_env("nonsense") + assert_response :unauthorized + end + + test "changing data requires a write-endowed access token" do + post boards_path(format: :json), params: { board: { name: "My new board" } }, env: @jasons_bearer_token + assert_response :unauthorized + + post boards_path(format: :json), params: { board: { name: "My new board" } }, env: @davids_bearer_token + assert_response :success + end + + private + def bearer_token_env(token) + { "HTTP_AUTHORIZATION" => "Bearer #{token}" } + end +end diff --git a/test/controllers/api_authentication_test.rb b/test/controllers/magic_link_api_test.rb similarity index 81% rename from test/controllers/api_authentication_test.rb rename to test/controllers/magic_link_api_test.rb index b662c6087..8a5ca1b03 100644 --- a/test/controllers/api_authentication_test.rb +++ b/test/controllers/magic_link_api_test.rb @@ -1,11 +1,6 @@ require "test_helper" -class ApiAuthenticationTest < ActionDispatch::IntegrationTest - setup do - @davids_bearer_token = bearer_token_env(identity_access_tokens(:davids_api_token).token) - @jasons_bearer_token = bearer_token_env(identity_access_tokens(:jasons_api_token).token) - end - +class MagicLinkApiTest < ActionDispatch::IntegrationTest test "request a magic link" do untenanted do post session_path(format: :json), params: { email_address: identities(:david).email_address } @@ -92,24 +87,6 @@ class ApiAuthenticationTest < ActionDispatch::IntegrationTest end end - test "authenticate with valid access token" do - get boards_path(format: :json), env: @davids_bearer_token - assert_response :success - end - - test "fail to authenticate with invalid access token" do - get boards_path(format: :json), env: bearer_token_env("nonsense") - assert_response :unauthorized - end - - test "changing data requires a write-endowed access token" do - post boards_path(format: :json), params: { board: { name: "My new board" } }, env: @jasons_bearer_token - assert_response :unauthorized - - post boards_path(format: :json), params: { board: { name: "My new board" } }, env: @davids_bearer_token - assert_response :success - end - test "create session for new user via JSON" do new_email = "new-user-#{SecureRandom.hex(6)}@example.com" @@ -136,10 +113,6 @@ class ApiAuthenticationTest < ActionDispatch::IntegrationTest end private - def bearer_token_env(token) - { "HTTP_AUTHORIZATION" => "Bearer #{token}" } - end - def pending_authentication_token_for(email_address) Rails.application.message_verifier(:pending_authentication).generate(email_address, expires_in: 10.minutes) end From fb487f598c1a19542e76192f1a7d0f487c251805 Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Fri, 12 Dec 2025 13:28:54 -0600 Subject: [PATCH 20/29] Restore sessions_controller test on creating invalid email address --- test/controllers/sessions_controller_test.rb | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/test/controllers/sessions_controller_test.rb b/test/controllers/sessions_controller_test.rb index 31fb84dd6..4dc5d98a0 100644 --- a/test/controllers/sessions_controller_test.rb +++ b/test/controllers/sessions_controller_test.rb @@ -60,13 +60,18 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest end end - test "create with invalid email address still redirects to prevent enumeration" do - untenanted do - assert_no_difference -> { Identity.count } do - post session_path, params: { email_address: "not-a-valid-email" } - end + test "create with invalid email address" do + # Avoid Sentry exceptions when attackers try to stuff invalid emails. The browser performs form + # field validation that should normally prevent this from occurring, so I'm not worried about + # returning proper validation errors. + without_action_dispatch_exception_handling do + untenanted do + assert_no_difference -> { Identity.count } do + post session_path, params: { email_address: "not-a-valid-email" } + end - assert_redirected_to session_magic_link_path + assert_redirected_to session_magic_link_path + end end end From cddddcf83a156462adc93ea7b93cc1c0a08cd368 Mon Sep 17 00:00:00 2001 From: Fernando Olivares Date: Fri, 12 Dec 2025 13:39:39 -0600 Subject: [PATCH 21/29] Fix due to unit test when creating with invalid emails --- app/controllers/sessions_controller.rb | 19 +++++++++++++++---- test/controllers/magic_link_api_test.rb | 5 ++--- test/controllers/sessions_controller_test.rb | 2 +- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 6513a42be..1781b40bc 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -9,20 +9,31 @@ class SessionsController < ApplicationController end def create - magic_link = magic_link_from_sign_in_or_sign_up + if identity = Identity.find_by_email_address(email_address) + magic_link = identity.send_magic_link + else + signup = Signup.new(email_address: email_address) + if signup.valid?(:identity_creation) + magic_link = signup.create_identity if Account.accepting_signups? + else + head :unprocessable_entity + return + end + end + serve_development_magic_link magic_link respond_to do |format| format.html do if magic_link.present? - redirect_to_session_magic_link magic_link + redirect_to_session_magic_link magic_link else redirect_to session_magic_link_path end end - format.json do - render json: { pending_authentication_token: pending_authentication_token_for(email_address) }, status: :created + format.json do + render json: { pending_authentication_token: pending_authentication_token_for(email_address) }, status: :created end end end diff --git a/test/controllers/magic_link_api_test.rb b/test/controllers/magic_link_api_test.rb index 8a5ca1b03..a1d494d48 100644 --- a/test/controllers/magic_link_api_test.rb +++ b/test/controllers/magic_link_api_test.rb @@ -102,13 +102,12 @@ class MagicLinkApiTest < ActionDispatch::IntegrationTest end end - test "create session with invalid email via JSON still returns token to prevent enumeration" do + test "create session with invalid email via JSON" do untenanted do assert_no_difference -> { Identity.count } do post session_path(format: :json), params: { email_address: "not-a-valid-email" } end - assert_response :created - assert @response.parsed_body["pending_authentication_token"].present? + assert_response :unprocessable_entity end end diff --git a/test/controllers/sessions_controller_test.rb b/test/controllers/sessions_controller_test.rb index 4dc5d98a0..74b336a4f 100644 --- a/test/controllers/sessions_controller_test.rb +++ b/test/controllers/sessions_controller_test.rb @@ -70,7 +70,7 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest post session_path, params: { email_address: "not-a-valid-email" } end - assert_redirected_to session_magic_link_path + assert_response :unprocessable_entity end end end From 1a1f4a077b5306427d8c8375c8ba6ca1a9769040 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Tue, 16 Dec 2025 18:23:04 +0100 Subject: [PATCH 22/29] Simplify auth logic --- app/controllers/concerns/authentication.rb | 45 ++++++++++--------- .../sessions/magic_links_controller.rb | 15 ++++++- app/controllers/sessions_controller.rb | 30 +++++-------- test/controllers/magic_link_api_test.rb | 2 +- test/controllers/sessions_controller_test.rb | 3 +- 5 files changed, 50 insertions(+), 45 deletions(-) diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index dbea14ada..aa3563e90 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -108,36 +108,39 @@ module Authentication end end - def email_address_pending_authentication_matches?(email_address) - if ActiveSupport::SecurityUtils.secure_compare(email_address, email_address_pending_authentication || "") - session.delete(:email_address_pending_authentication) - true - else - false - end - end - def email_address_pending_authentication - if request.format.json? - verified_pending_authentication_token - else - session[:email_address_pending_authentication] - end + pending_authentication_token_verifier.verified(pending_authentication_token) end - def pending_authentication_token_for(email_address) - Rails.application.message_verifier(:pending_authentication).generate(email_address, expires_in: 10.minutes) + def pending_authentication_token_verifier + Rails.application.message_verifier(:pending_authentication) end - def verified_pending_authentication_token - Rails.application.message_verifier(:pending_authentication).verified(params[:pending_authentication_token]) + def pending_authentication_token + cookies[:pending_authentication_token] end - def redirect_to_session_magic_link(magic_link, return_to: nil) + def clear_pending_authentication_token + cookies.delete(:pending_authentication_token) + end + + def redirect_to_session_magic_link(magic_link, email_address: magic_link&.identity&.email_address, return_to: nil) + expires_at = magic_link&.expires_at || MagicLink::EXPIRATION_TIME.from_now + + cookies[:pending_authentication_token] = { + value: pending_authentication_token_verifier.generate(email_address, expires_at: expires_at), + httponly: true, + same_site: :lax, + expires: expires_at + } + serve_development_magic_link(magic_link) - session[:email_address_pending_authentication] = magic_link.identity.email_address if magic_link session[:return_to_after_authenticating] = return_to if return_to - redirect_to main_app.session_magic_link_path(script_name: nil) + + respond_to do |format| + format.html { redirect_to main_app.session_magic_link_url(script_name: nil) } + format.json { render json: { pending_authentication_token: pending_authentication_token }, status: :created } + end end def serve_development_magic_link(magic_link) diff --git a/app/controllers/sessions/magic_links_controller.rb b/app/controllers/sessions/magic_links_controller.rb index d83e7f807..38d1350bb 100644 --- a/app/controllers/sessions/magic_links_controller.rb +++ b/app/controllers/sessions/magic_links_controller.rb @@ -33,7 +33,8 @@ class Sessions::MagicLinksController < ApplicationController end def respond_to_valid_code_from(magic_link) - if email_address_pending_authentication_matches?(magic_link.identity.email_address) + if ActiveSupport::SecurityUtils.secure_compare(email_address_pending_authentication || "", magic_link.identity.email_address) + clear_pending_authentication_token start_new_session_for magic_link.identity respond_to do |format| @@ -41,7 +42,9 @@ class Sessions::MagicLinksController < ApplicationController format.json { render json: { session_token: cookies[:session_token] } } end else - alert_message = "Authentication failed. Please try again." + clear_pending_authentication_token + alert_message = "Something went wrong. Please try again." + respond_to do |format| format.html { redirect_to new_session_path, alert: alert_message } format.json { render json: { message: alert_message }, status: :unauthorized } @@ -56,6 +59,14 @@ class Sessions::MagicLinksController < ApplicationController end end + def pending_authentication_token + if request.format.json? + params[:pending_authentication_token] + else + super + end + end + def after_sign_in_url(magic_link) if magic_link.for_sign_up? new_signup_completion_path diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 1781b40bc..29d795fb2 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -11,29 +11,19 @@ class SessionsController < ApplicationController def create if identity = Identity.find_by_email_address(email_address) magic_link = identity.send_magic_link - else + elsif Account.accepting_signups? signup = Signup.new(email_address: email_address) - if signup.valid?(:identity_creation) - magic_link = signup.create_identity if Account.accepting_signups? - else - head :unprocessable_entity - return - end + magic_link = signup.create_identity if signup.valid?(:identity_creation) end - serve_development_magic_link magic_link - - respond_to do |format| - format.html do - if magic_link.present? - redirect_to_session_magic_link magic_link - else - redirect_to session_magic_link_path - end - end - - format.json do - render json: { pending_authentication_token: pending_authentication_token_for(email_address) }, status: :created + if magic_link + redirect_to_session_magic_link magic_link + elsif !Account.accepting_signups? + redirect_to_session_magic_link nil, email_address: email_address + else + respond_to do |format| + format.html { redirect_to new_session_path, alert: "Something went wrong" } + format.json { render json: { message: "Something went wrong" }, status: :unprocessable_entity } end end end diff --git a/test/controllers/magic_link_api_test.rb b/test/controllers/magic_link_api_test.rb index a1d494d48..4b7f7f444 100644 --- a/test/controllers/magic_link_api_test.rb +++ b/test/controllers/magic_link_api_test.rb @@ -67,7 +67,7 @@ class MagicLinkApiTest < ActionDispatch::IntegrationTest untenanted do post session_magic_link_path(format: :json), params: { code: magic_link.code, pending_authentication_token: pending_token } assert_response :unauthorized - assert_equal "Authentication failed. Please try again.", @response.parsed_body["message"] + assert_equal "Something went wrong. Please try again.", @response.parsed_body["message"] end end diff --git a/test/controllers/sessions_controller_test.rb b/test/controllers/sessions_controller_test.rb index 74b336a4f..07c58ed74 100644 --- a/test/controllers/sessions_controller_test.rb +++ b/test/controllers/sessions_controller_test.rb @@ -70,7 +70,8 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest post session_path, params: { email_address: "not-a-valid-email" } end - assert_response :unprocessable_entity + assert_response :redirect + assert_redirected_to new_session_path end end end From 23fc7b594f284697ed915f10e0d21278016d5e3a Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Tue, 16 Dec 2025 18:38:11 +0100 Subject: [PATCH 23/29] Split tests by controller or responsibility --- test/controllers/api_test.rb | 16 +++ test/controllers/magic_link_api_test.rb | 118 ------------------ .../sessions/magic_links_controller_test.rb | 68 ++++++++++ test/controllers/sessions_controller_test.rb | 31 +++++ 4 files changed, 115 insertions(+), 118 deletions(-) delete mode 100644 test/controllers/magic_link_api_test.rb diff --git a/test/controllers/api_test.rb b/test/controllers/api_test.rb index a48511cf8..f6bf18ca6 100644 --- a/test/controllers/api_test.rb +++ b/test/controllers/api_test.rb @@ -6,6 +6,22 @@ class ApiTest < ActionDispatch::IntegrationTest @jasons_bearer_token = bearer_token_env(identity_access_tokens(:jasons_api_token).token) end + test "authenticate with user credentials" do + identity = identities(:david) + + untenanted do + post session_path(format: :json), params: { email_address: identity.email_address } + assert_response :created + pending_token = @response.parsed_body["pending_authentication_token"] + assert pending_token.present? + + magic_link = MagicLink.last + post session_magic_link_path(format: :json), params: { code: magic_link.code, pending_authentication_token: pending_token } + assert_response :success + assert @response.parsed_body["session_token"].present? + end + end + test "authenticate with valid access token" do get boards_path(format: :json), env: @davids_bearer_token assert_response :success diff --git a/test/controllers/magic_link_api_test.rb b/test/controllers/magic_link_api_test.rb deleted file mode 100644 index 4b7f7f444..000000000 --- a/test/controllers/magic_link_api_test.rb +++ /dev/null @@ -1,118 +0,0 @@ -require "test_helper" - -class MagicLinkApiTest < ActionDispatch::IntegrationTest - test "request a magic link" do - untenanted do - post session_path(format: :json), params: { email_address: identities(:david).email_address } - assert_response :created - end - end - - test "magic link consumption" do - identity = identities(:david) - magic_link = identity.send_magic_link - pending_token = pending_authentication_token_for(identity.email_address) - - untenanted do - post session_magic_link_path(format: :json), params: { code: magic_link.code, pending_authentication_token: pending_token } - assert_response :success - assert @response.parsed_body["session_token"].present? - end - end - - test "full JSON authentication flow with pending_authentication_token" do - identity = identities(:david) - - untenanted do - post session_path(format: :json), params: { email_address: identity.email_address } - assert_response :created - pending_token = @response.parsed_body["pending_authentication_token"] - assert pending_token.present? - - magic_link = MagicLink.last - post session_magic_link_path(format: :json), params: { code: magic_link.code, pending_authentication_token: pending_token } - assert_response :success - assert @response.parsed_body["session_token"].present? - end - end - - test "magic link consumption without pending_authentication_token returns unauthorized" do - identity = identities(:david) - magic_link = identity.send_magic_link - - untenanted do - post session_magic_link_path(format: :json), params: { code: magic_link.code } - assert_response :unauthorized - assert_equal "Enter your email address to sign in.", @response.parsed_body["message"] - end - end - - test "magic link consumption with invalid code via JSON" do - identity = identities(:david) - pending_token = pending_authentication_token_for(identity.email_address) - - untenanted do - post session_magic_link_path(format: :json), params: { code: "INVALID", pending_authentication_token: pending_token } - assert_response :unauthorized - assert_equal "Try another code.", @response.parsed_body["message"] - end - end - - test "magic link consumption with cross-user code via JSON returns unauthorized" do - identity = identities(:david) - other_identity = identities(:jason) - magic_link = other_identity.send_magic_link - pending_token = pending_authentication_token_for(identity.email_address) - - untenanted do - post session_magic_link_path(format: :json), params: { code: magic_link.code, pending_authentication_token: pending_token } - assert_response :unauthorized - assert_equal "Something went wrong. Please try again.", @response.parsed_body["message"] - end - end - - test "magic link consumption with expired pending_authentication_token" do - identity = identities(:david) - magic_link = identity.send_magic_link - - expired_token = nil - travel_to 15.minutes.ago do - expired_token = pending_authentication_token_for(identity.email_address) - end - - untenanted do - post session_magic_link_path(format: :json), params: { code: magic_link.code, pending_authentication_token: expired_token } - assert_response :unauthorized - assert_equal "Enter your email address to sign in.", @response.parsed_body["message"] - end - end - - test "create session for new user via JSON" do - new_email = "new-user-#{SecureRandom.hex(6)}@example.com" - - untenanted do - assert_difference -> { Identity.count }, 1 do - assert_difference -> { MagicLink.count }, 1 do - post session_path(format: :json), params: { email_address: new_email } - end - end - assert_response :created - assert @response.parsed_body["pending_authentication_token"].present? - assert MagicLink.last.for_sign_up? - end - end - - test "create session with invalid email via JSON" do - untenanted do - assert_no_difference -> { Identity.count } do - post session_path(format: :json), params: { email_address: "not-a-valid-email" } - end - assert_response :unprocessable_entity - end - end - - private - def pending_authentication_token_for(email_address) - Rails.application.message_verifier(:pending_authentication).generate(email_address, expires_in: 10.minutes) - end -end diff --git a/test/controllers/sessions/magic_links_controller_test.rb b/test/controllers/sessions/magic_links_controller_test.rb index 5a4a48e51..6d07a9c4a 100644 --- a/test/controllers/sessions/magic_links_controller_test.rb +++ b/test/controllers/sessions/magic_links_controller_test.rb @@ -79,4 +79,72 @@ class Sessions::MagicLinksControllerTest < ActionDispatch::IntegrationTest assert_response :redirect, "Expired magic link should redirect" assert MagicLink.exists?(expired_link.id), "Expired magic link should not be consumed" end + + test "create via JSON" do + identity = identities(:david) + magic_link = identity.send_magic_link + pending_token = pending_authentication_token_for(identity.email_address) + + untenanted do + post session_magic_link_path(format: :json), params: { code: magic_link.code, pending_authentication_token: pending_token } + assert_response :success + assert @response.parsed_body["session_token"].present? + end + end + + test "create via JSON without pending_authentication_token" do + identity = identities(:david) + magic_link = identity.send_magic_link + + untenanted do + post session_magic_link_path(format: :json), params: { code: magic_link.code } + assert_response :unauthorized + assert_equal "Enter your email address to sign in.", @response.parsed_body["message"] + end + end + + test "create via JSON with invalid code" do + identity = identities(:david) + pending_token = pending_authentication_token_for(identity.email_address) + + untenanted do + post session_magic_link_path(format: :json), params: { code: "INVALID", pending_authentication_token: pending_token } + assert_response :unauthorized + assert_equal "Try another code.", @response.parsed_body["message"] + end + end + + test "create via JSON with cross-user code" do + identity = identities(:david) + other_identity = identities(:jason) + magic_link = other_identity.send_magic_link + pending_token = pending_authentication_token_for(identity.email_address) + + untenanted do + post session_magic_link_path(format: :json), params: { code: magic_link.code, pending_authentication_token: pending_token } + assert_response :unauthorized + assert_equal "Something went wrong. Please try again.", @response.parsed_body["message"] + end + end + + test "create via JSON with expired pending_authentication_token" do + identity = identities(:david) + magic_link = identity.send_magic_link + + expired_token = nil + travel_to 15.minutes.ago do + expired_token = pending_authentication_token_for(identity.email_address) + end + + untenanted do + post session_magic_link_path(format: :json), params: { code: magic_link.code, pending_authentication_token: expired_token } + assert_response :unauthorized + assert_equal "Enter your email address to sign in.", @response.parsed_body["message"] + end + end + + private + def pending_authentication_token_for(email_address) + Sessions::MagicLinksController.new.send(:pending_authentication_token_verifier).generate(email_address, expires_in: 10.minutes) + end end diff --git a/test/controllers/sessions_controller_test.rb b/test/controllers/sessions_controller_test.rb index 07c58ed74..1a0ddb061 100644 --- a/test/controllers/sessions_controller_test.rb +++ b/test/controllers/sessions_controller_test.rb @@ -86,4 +86,35 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest assert_not cookies[:session_token].present? end end + + test "create via JSON" do + untenanted do + post session_path(format: :json), params: { email_address: identities(:david).email_address } + assert_response :created + end + end + + test "create for a new user via JSON" do + new_email = "new-user-#{SecureRandom.hex(6)}@example.com" + + untenanted do + assert_difference -> { Identity.count }, 1 do + assert_difference -> { MagicLink.count }, 1 do + post session_path(format: :json), params: { email_address: new_email } + end + end + assert_response :created + assert @response.parsed_body["pending_authentication_token"].present? + assert MagicLink.last.for_sign_up? + end + end + + test "create with invalid email address via JSON" do + untenanted do + assert_no_difference -> { Identity.count } do + post session_path(format: :json), params: { email_address: "not-a-valid-email" } + end + assert_response :unprocessable_entity + end + end end From e0270c6c49920b0910c3bb1f9c4ab67be708c3a6 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Fri, 19 Dec 2025 16:09:46 +0100 Subject: [PATCH 24/29] Clean up interfaces I talked to the mobile team, and to keep things simple we agreed to send the token via a cookie. --- app/controllers/concerns/authentication.rb | 51 +------------ .../concerns/authentication/via_magic_link.rb | 76 +++++++++++++++++++ .../sessions/magic_links_controller.rb | 52 ++++++------- app/controllers/sessions_controller.rb | 36 +++++---- .../sessions/magic_links_controller_test.rb | 28 +++---- 5 files changed, 136 insertions(+), 107 deletions(-) create mode 100644 app/controllers/concerns/authentication/via_magic_link.rb diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index aa3563e90..146f271f5 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -4,13 +4,12 @@ module Authentication included do before_action :require_account # Checking and setting account must happen first before_action :require_authentication - after_action :ensure_development_magic_link_not_leaked helper_method :authenticated? helper_method :email_address_pending_authentication etag { Current.identity.id if authenticated? } - include LoginHelper + include Authentication::ViaMagicLink, LoginHelper end class_methods do @@ -101,52 +100,4 @@ module Authentication Current.session.destroy cookies.delete(:session_token) end - - def ensure_development_magic_link_not_leaked - unless Rails.env.development? - raise "Leaking magic link via flash in #{Rails.env}?" if flash[:magic_link_code].present? - end - end - - def email_address_pending_authentication - pending_authentication_token_verifier.verified(pending_authentication_token) - end - - def pending_authentication_token_verifier - Rails.application.message_verifier(:pending_authentication) - end - - def pending_authentication_token - cookies[:pending_authentication_token] - end - - def clear_pending_authentication_token - cookies.delete(:pending_authentication_token) - end - - def redirect_to_session_magic_link(magic_link, email_address: magic_link&.identity&.email_address, return_to: nil) - expires_at = magic_link&.expires_at || MagicLink::EXPIRATION_TIME.from_now - - cookies[:pending_authentication_token] = { - value: pending_authentication_token_verifier.generate(email_address, expires_at: expires_at), - httponly: true, - same_site: :lax, - expires: expires_at - } - - serve_development_magic_link(magic_link) - session[:return_to_after_authenticating] = return_to if return_to - - respond_to do |format| - format.html { redirect_to main_app.session_magic_link_url(script_name: nil) } - format.json { render json: { pending_authentication_token: pending_authentication_token }, status: :created } - end - end - - def serve_development_magic_link(magic_link) - if Rails.env.development? && magic_link.present? - flash[:magic_link_code] = magic_link.code - response.set_header("X-Magic-Link-Code", magic_link.code) - end - end end diff --git a/app/controllers/concerns/authentication/via_magic_link.rb b/app/controllers/concerns/authentication/via_magic_link.rb new file mode 100644 index 000000000..669de15c1 --- /dev/null +++ b/app/controllers/concerns/authentication/via_magic_link.rb @@ -0,0 +1,76 @@ +module Authentication::ViaMagicLink + extend ActiveSupport::Concern + + FakeIdentity = Data.define(:email_address) + FakeMagicLink = Data.define(:email_address) do + def identity + FakeIdentity.new(email_address) + end + + def code + "XPHONY" + end + + def expires_at + MagicLink::EXPIRATION_TIME.from_now + end + end + + included do + after_action :ensure_development_magic_link_not_leaked + end + + private + def ensure_development_magic_link_not_leaked + unless Rails.env.development? + raise "Leaking magic link via flash in #{Rails.env}?" if flash[:magic_link_code].present? + end + end + + def redirect_to_fake_session_magic_link(email_address, **options) + redirect_to_session_magic_link FakeMagicLink.new(email_address), **options + end + + def redirect_to_session_magic_link(magic_link, return_to: nil) + serve_development_magic_link(magic_link) + set_pending_authentication_token(magic_link) + session[:return_to_after_authenticating] = return_to if return_to + + respond_to do |format| + format.html { redirect_to main_app.session_magic_link_url(script_name: nil) } + format.json { render json: { pending_authentication_token: pending_authentication_token }, status: :created } + end + end + + def serve_development_magic_link(magic_link) + if Rails.env.development? && magic_link.present? + flash[:magic_link_code] = magic_link.code + response.set_header("X-Magic-Link-Code", magic_link.code) + end + end + + def set_pending_authentication_token(magic_link) + cookies[:pending_authentication_token] = { + value: pending_authentication_token_verifier.generate(magic_link.identity.email_address, expires_at: magic_link.expires_at), + httponly: true, + same_site: :lax, + expires: magic_link.expires_at + } + end + + def email_address_pending_authentication + pending_authentication_token_verifier.verified(pending_authentication_token) + end + + def pending_authentication_token_verifier + Rails.application.message_verifier(:pending_authentication) + end + + def pending_authentication_token + cookies[:pending_authentication_token] + end + + def clear_pending_authentication_token + cookies.delete(:pending_authentication_token) + end +end diff --git a/app/controllers/sessions/magic_links_controller.rb b/app/controllers/sessions/magic_links_controller.rb index 38d1350bb..b3a376d49 100644 --- a/app/controllers/sessions/magic_links_controller.rb +++ b/app/controllers/sessions/magic_links_controller.rb @@ -11,9 +11,9 @@ class Sessions::MagicLinksController < ApplicationController def create if magic_link = MagicLink.consume(code) - respond_to_valid_code_from magic_link + handle_valid_code_for magic_link else - respond_to_invalid_code + handle_invalid_code end end @@ -32,41 +32,41 @@ class Sessions::MagicLinksController < ApplicationController params.expect(:code) end - def respond_to_valid_code_from(magic_link) + def handle_valid_code_for(magic_link) if ActiveSupport::SecurityUtils.secure_compare(email_address_pending_authentication || "", magic_link.identity.email_address) - clear_pending_authentication_token - start_new_session_for magic_link.identity - - respond_to do |format| - format.html { redirect_to after_sign_in_url(magic_link) } - format.json { render json: { session_token: cookies[:session_token] } } - end + handle_sign_in_with magic_link else - clear_pending_authentication_token - alert_message = "Something went wrong. Please try again." - - respond_to do |format| - format.html { redirect_to new_session_path, alert: alert_message } - format.json { render json: { message: alert_message }, status: :unauthorized } - end + handle_email_address_mismatch end end - def respond_to_invalid_code + def handle_sign_in_with(magic_link) + clear_pending_authentication_token + start_new_session_for magic_link.identity + + respond_to do |format| + format.html { redirect_to after_sign_in_url(magic_link) } + format.json { render json: { session_token: cookies[:session_token] } } + end + end + + def handle_email_address_mismatch + clear_pending_authentication_token + alert_message = "Something went wrong. Please try again." + + respond_to do |format| + format.html { redirect_to new_session_path, alert: alert_message } + format.json { render json: { message: alert_message }, status: :unauthorized } + end + end + + def handle_invalid_code respond_to do |format| format.html { redirect_to session_magic_link_path, flash: { shake: true } } format.json { render json: { message: "Try another code." }, status: :unauthorized } end end - def pending_authentication_token - if request.format.json? - params[:pending_authentication_token] - else - super - end - end - def after_sign_in_url(magic_link) if magic_link.for_sign_up? new_signup_completion_path diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 29d795fb2..820ac81ed 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -9,22 +9,12 @@ class SessionsController < ApplicationController end def create - if identity = Identity.find_by_email_address(email_address) - magic_link = identity.send_magic_link + if identity = Identity.find_by(email_address: email_address) + handle_sign_in_for identity elsif Account.accepting_signups? - signup = Signup.new(email_address: email_address) - magic_link = signup.create_identity if signup.valid?(:identity_creation) - end - - if magic_link - redirect_to_session_magic_link magic_link - elsif !Account.accepting_signups? - redirect_to_session_magic_link nil, email_address: email_address + handle_sign_up else - respond_to do |format| - format.html { redirect_to new_session_path, alert: "Something went wrong" } - format.json { render json: { message: "Something went wrong" }, status: :unprocessable_entity } - end + redirect_to_fake_session_magic_link email_address end end @@ -55,4 +45,22 @@ class SessionsController < ApplicationController format.json { render json: { message: rate_limit_exceeded_message }, status: :too_many_requests } end end + + def handle_sign_in_for(identity) + redirect_to_session_magic_link identity.send_magic_link + end + + def handle_sign_up + signup = Signup.new(email_address: email_address) + + if signup.valid?(:identity_creation) + magic_link = signup.create_identity + redirect_to_session_magic_link magic_link + else + respond_to do |format| + format.html { redirect_to new_session_path, alert: "Something went wrong" } + format.json { render json: { message: "Something went wrong" }, status: :unprocessable_entity } + end + end + end end diff --git a/test/controllers/sessions/magic_links_controller_test.rb b/test/controllers/sessions/magic_links_controller_test.rb index 6d07a9c4a..83b228416 100644 --- a/test/controllers/sessions/magic_links_controller_test.rb +++ b/test/controllers/sessions/magic_links_controller_test.rb @@ -83,10 +83,10 @@ class Sessions::MagicLinksControllerTest < ActionDispatch::IntegrationTest test "create via JSON" do identity = identities(:david) magic_link = identity.send_magic_link - pending_token = pending_authentication_token_for(identity.email_address) untenanted do - post session_magic_link_path(format: :json), params: { code: magic_link.code, pending_authentication_token: pending_token } + post session_path(format: :json), params: { email_address: identity.email_address } + post session_magic_link_path(format: :json), params: { code: magic_link.code } assert_response :success assert @response.parsed_body["session_token"].present? end @@ -105,10 +105,10 @@ class Sessions::MagicLinksControllerTest < ActionDispatch::IntegrationTest test "create via JSON with invalid code" do identity = identities(:david) - pending_token = pending_authentication_token_for(identity.email_address) untenanted do - post session_magic_link_path(format: :json), params: { code: "INVALID", pending_authentication_token: pending_token } + post session_path(format: :json), params: { email_address: identity.email_address } + post session_magic_link_path(format: :json), params: { code: "INVALID" } assert_response :unauthorized assert_equal "Try another code.", @response.parsed_body["message"] end @@ -118,10 +118,10 @@ class Sessions::MagicLinksControllerTest < ActionDispatch::IntegrationTest identity = identities(:david) other_identity = identities(:jason) magic_link = other_identity.send_magic_link - pending_token = pending_authentication_token_for(identity.email_address) untenanted do - post session_magic_link_path(format: :json), params: { code: magic_link.code, pending_authentication_token: pending_token } + post session_path(format: :json), params: { email_address: identity.email_address } + post session_magic_link_path(format: :json), params: { code: magic_link.code } assert_response :unauthorized assert_equal "Something went wrong. Please try again.", @response.parsed_body["message"] end @@ -131,20 +131,14 @@ class Sessions::MagicLinksControllerTest < ActionDispatch::IntegrationTest identity = identities(:david) magic_link = identity.send_magic_link - expired_token = nil - travel_to 15.minutes.ago do - expired_token = pending_authentication_token_for(identity.email_address) - end - untenanted do - post session_magic_link_path(format: :json), params: { code: magic_link.code, pending_authentication_token: expired_token } + travel_to 20.minutes.ago do + post session_path(format: :json), params: { email_address: identity.email_address } + end + + post session_magic_link_path(format: :json), params: { code: magic_link.code } assert_response :unauthorized assert_equal "Enter your email address to sign in.", @response.parsed_body["message"] end end - - private - def pending_authentication_token_for(email_address) - Sessions::MagicLinksController.new.send(:pending_authentication_token_verifier).generate(email_address, expires_in: 10.minutes) - end end From 52d57c4681112b8393ab7d2af444418286e60998 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Fri, 19 Dec 2025 16:13:53 +0100 Subject: [PATCH 25/29] Tidy up session_token --- app/controllers/concerns/authentication.rb | 4 ++++ app/controllers/sessions/magic_links_controller.rb | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index 146f271f5..05efbf4fc 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -100,4 +100,8 @@ module Authentication Current.session.destroy cookies.delete(:session_token) end + + def session_token + cookies[:session_token] + end end diff --git a/app/controllers/sessions/magic_links_controller.rb b/app/controllers/sessions/magic_links_controller.rb index b3a376d49..c96fe33bf 100644 --- a/app/controllers/sessions/magic_links_controller.rb +++ b/app/controllers/sessions/magic_links_controller.rb @@ -46,7 +46,7 @@ class Sessions::MagicLinksController < ApplicationController respond_to do |format| format.html { redirect_to after_sign_in_url(magic_link) } - format.json { render json: { session_token: cookies[:session_token] } } + format.json { render json: { session_token: session_token } } end end From 3af8bdbe37963669184defad2028addbe9aeacce Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Fri, 19 Dec 2025 16:29:46 +0100 Subject: [PATCH 26/29] Replace FakeMagicLink with a temporary object --- .../concerns/authentication/via_magic_link.rb | 23 ++++++------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/app/controllers/concerns/authentication/via_magic_link.rb b/app/controllers/concerns/authentication/via_magic_link.rb index 669de15c1..c91cda6bf 100644 --- a/app/controllers/concerns/authentication/via_magic_link.rb +++ b/app/controllers/concerns/authentication/via_magic_link.rb @@ -1,21 +1,6 @@ module Authentication::ViaMagicLink extend ActiveSupport::Concern - FakeIdentity = Data.define(:email_address) - FakeMagicLink = Data.define(:email_address) do - def identity - FakeIdentity.new(email_address) - end - - def code - "XPHONY" - end - - def expires_at - MagicLink::EXPIRATION_TIME.from_now - end - end - included do after_action :ensure_development_magic_link_not_leaked end @@ -28,7 +13,13 @@ module Authentication::ViaMagicLink end def redirect_to_fake_session_magic_link(email_address, **options) - redirect_to_session_magic_link FakeMagicLink.new(email_address), **options + fake_magic_link = MagicLink.new( + identity: Identity.new(email_address: email_address), + code: SecureRandom.base32(6), + expires_at: 15.minutes.from_now + ) + + redirect_to_session_magic_link fake_magic_link, **options end def redirect_to_session_magic_link(magic_link, return_to: nil) From df1dfde2b38d542d3f2396caff7fb9b29c62fde0 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Fri, 19 Dec 2025 18:35:56 +0100 Subject: [PATCH 27/29] Use same constant for fake magic links --- app/controllers/concerns/authentication/via_magic_link.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/concerns/authentication/via_magic_link.rb b/app/controllers/concerns/authentication/via_magic_link.rb index c91cda6bf..e9c0dc415 100644 --- a/app/controllers/concerns/authentication/via_magic_link.rb +++ b/app/controllers/concerns/authentication/via_magic_link.rb @@ -16,7 +16,7 @@ module Authentication::ViaMagicLink fake_magic_link = MagicLink.new( identity: Identity.new(email_address: email_address), code: SecureRandom.base32(6), - expires_at: 15.minutes.from_now + expires_at: MagicLink::EXPIRATION_TIME.from_now ) redirect_to_session_magic_link fake_magic_link, **options From ca388c2b840f3565986f902cc9019b68213d1032 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Fri, 19 Dec 2025 18:42:23 +0100 Subject: [PATCH 28/29] Replace handle_ naming --- .../sessions/magic_links_controller.rb | 16 ++++++++-------- app/controllers/sessions_controller.rb | 8 ++++---- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/app/controllers/sessions/magic_links_controller.rb b/app/controllers/sessions/magic_links_controller.rb index c96fe33bf..32257d941 100644 --- a/app/controllers/sessions/magic_links_controller.rb +++ b/app/controllers/sessions/magic_links_controller.rb @@ -11,9 +11,9 @@ class Sessions::MagicLinksController < ApplicationController def create if magic_link = MagicLink.consume(code) - handle_valid_code_for magic_link + authenticate magic_link else - handle_invalid_code + invalid_code end end @@ -32,15 +32,15 @@ class Sessions::MagicLinksController < ApplicationController params.expect(:code) end - def handle_valid_code_for(magic_link) + def authenticate(magic_link) if ActiveSupport::SecurityUtils.secure_compare(email_address_pending_authentication || "", magic_link.identity.email_address) - handle_sign_in_with magic_link + sign_in magic_link else - handle_email_address_mismatch + email_address_mismatch end end - def handle_sign_in_with(magic_link) + def sign_in(magic_link) clear_pending_authentication_token start_new_session_for magic_link.identity @@ -50,7 +50,7 @@ class Sessions::MagicLinksController < ApplicationController end end - def handle_email_address_mismatch + def email_address_mismatch clear_pending_authentication_token alert_message = "Something went wrong. Please try again." @@ -60,7 +60,7 @@ class Sessions::MagicLinksController < ApplicationController end end - def handle_invalid_code + def invalid_code respond_to do |format| format.html { redirect_to session_magic_link_path, flash: { shake: true } } format.json { render json: { message: "Try another code." }, status: :unauthorized } diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 820ac81ed..d0de9e84c 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -10,9 +10,9 @@ class SessionsController < ApplicationController def create if identity = Identity.find_by(email_address: email_address) - handle_sign_in_for identity + sign_in identity elsif Account.accepting_signups? - handle_sign_up + sign_up else redirect_to_fake_session_magic_link email_address end @@ -46,11 +46,11 @@ class SessionsController < ApplicationController end end - def handle_sign_in_for(identity) + def sign_in(identity) redirect_to_session_magic_link identity.send_magic_link end - def handle_sign_up + def sign_up signup = Signup.new(email_address: email_address) if signup.valid?(:identity_creation) From 0cb4822ae0a62dff2accaac8529e8020e3ac1a88 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Fri, 19 Dec 2025 19:06:48 +0100 Subject: [PATCH 29/29] Document the new sign in method --- docs/API.md | 82 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/docs/API.md b/docs/API.md index 9cc95bffc..3c1d91977 100644 --- a/docs/API.md +++ b/docs/API.md @@ -5,6 +5,13 @@ a bot to perform various actions for you. ## Authentication +There are two ways to authenticate with the Fizzy API: + +1. **Personal access tokens** - Long-lived tokens for scripts and integrations +2. **Magic link authentication** - Session-based authentication for native apps + +### Personal Access Tokens + To use the API you'll need an access token. To get one, go to your profile, then, in the API section, click on "Personal access tokens" and then click on "Generate new access token". @@ -36,6 +43,81 @@ To authenticate a request using your access token, include it in the `Authorizat curl -H "Authorization: Bearer put-your-access-token-here" -H "Accept: application/json" https://app.fizzy.do/my/identity ``` +### Magic Link Authentication + +For native apps, you can authenticate users via magic links. This is a two-step process: + +#### 1. Request a magic link + +Send the user's email address to request a magic link be sent to them: + +```bash +curl -X POST \ + -H "Content-Type: application/json" \ + -H "Accept: application/json" \ + -d '{"email_address": "user@example.com"}' \ + https://app.fizzy.do/session +``` + +__Response:__ + +``` +HTTP/1.1 201 Created +Set-Cookie: pending_authentication_token=...; HttpOnly; SameSite=Lax +``` + +```json +{ + "pending_authentication_token": "eyJfcmFpbHMi..." +} +``` + +The response includes a `pending_authentication_token` both in the JSON body and as a cookie. +Native apps should store this token and include it as a cookie when submitting the magic link code. + +__Error responses:__ + +| Status Code | Description | +|--------|-------------| +| `422 Unprocessable entity` | Invalid email address, if sign ups are enabled and the value isn't a valid email address | +| `429 Too Many Requests` | Rate limit exceeded | + +#### 2. Submit the magic link code + +Once the user receives the magic link email, they'll have a 6-character code. Submit it to complete authentication: + +```bash +curl -X POST \ + -H "Content-Type: application/json" \ + -H "Accept: application/json" \ + -H "Cookie: pending_authentication_token=eyJfcmFpbHMi..." \ + -d '{"code": "ABC123"}' \ + https://app.fizzy.do/session/magic_link +``` + +__Response:__ + +```json +{ + "session_token": "eyJfcmFpbHMi..." +} +``` + +The `session_token` can be used to authenticate subsequent requests by including it as a cookie: + +```bash +curl -H "Cookie: session_token=eyJfcmFpbHMi..." \ + -H "Accept: application/json" \ + https://app.fizzy.do/my/identity +``` + +__Error responses:__ + +| Status Code | Description | +|--------|-------------| +| `401 Unauthorized` | Invalid `pending_authentication_token` or `code` | +| `429 Too Many Requests` | Rate limit exceeded | + ## Caching Most endpoints return [ETag](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/ETag) and [Cache-Control](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control) headers. You can use these to avoid re-downloading unchanged data.