Merge pull request #1941 from basecamp/mobile/native-log-in

Mobile: Native log in
This commit is contained in:
Stanko Krtalić
2025-12-19 19:26:58 +01:00
committed by GitHub
8 changed files with 356 additions and 56 deletions
+3 -32
View File
@@ -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
@@ -102,35 +101,7 @@ module Authentication
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_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
session[:email_address_pending_authentication]
end
def redirect_to_session_magic_link(magic_link, return_to: nil)
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)
end
def serve_development_magic_link(magic_link)
if Rails.env.development?
flash[:magic_link_code] = magic_link&.code
end
def session_token
cookies[:session_token]
end
end
@@ -0,0 +1,67 @@
module Authentication::ViaMagicLink
extend ActiveSupport::Concern
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)
fake_magic_link = MagicLink.new(
identity: Identity.new(email_address: email_address),
code: SecureRandom.base32(6),
expires_at: MagicLink::EXPIRATION_TIME.from_now
)
redirect_to_session_magic_link fake_magic_link, **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
@@ -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
authenticate magic_link
else
redirect_to session_magic_link_path, flash: { shake: true }
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,41 @@ class Sessions::MagicLinksController < ApplicationController
params.expect(:code)
end
def authenticate(magic_link)
if ActiveSupport::SecurityUtils.secure_compare(email_address_pending_authentication || "", magic_link.identity.email_address)
sign_in magic_link
else
email_address_mismatch
end
end
def sign_in(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: session_token } }
end
end
def 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 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 +74,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
+42 -10
View File
@@ -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"
@@ -9,16 +9,12 @@ class SessionsController < ApplicationController
end
def create
if identity = Identity.find_by_email_address(email_address)
redirect_to_session_magic_link identity.send_magic_link
if identity = Identity.find_by(email_address: email_address)
sign_in identity
elsif Account.accepting_signups?
sign_up
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
end
redirect_to_fake_session_magic_link email_address
end
end
@@ -28,7 +24,43 @@ 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)
signup.create_identity if signup.valid?(:identity_creation) && Account.accepting_signups?
end
end
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
def sign_in(identity)
redirect_to_session_magic_link identity.send_magic_link
end
def 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
+82
View File
@@ -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.
+16
View File
@@ -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
@@ -79,4 +79,66 @@ 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
untenanted do
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
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)
untenanted do
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
end
test "create via JSON with cross-user code" do
identity = identities(:david)
other_identity = identities(:jason)
magic_link = other_identity.send_magic_link
untenanted do
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
end
test "create via JSON with expired pending_authentication_token" do
identity = identities(:david)
magic_link = identity.send_magic_link
untenanted do
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
end
+33 -1
View File
@@ -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
@@ -85,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