Skip to content

The login machine

Sign-in is not a controller with branches. It is an ordered list of states, and the first one that cannot let you past names the prompt you see.

class Login
STATES = [
LoginStates::Setup,
LoginStates::Identifier,
LoginStates::Password,
LoginStates::FirstFactor,
LoginStates::OneTimePassword,
LoginStates::SecondFactor
].freeze
end

The order of STATES is the flow. Nothing else encodes it — there is no state diagram, no next_step column, and no controller deciding what comes after what.

def update
states.each(&:reload!)
states.each { |state| state.event!(event) } if event
states.each(&:factor!)
@prompt = SETTLED
rescue LoginState::PromptRequired => denial
@prompt = denial.prompt
end

Every state gets to reload, then to handle the event, then to insist. A state insists by raising PromptRequired, so the first unsatisfied state in declaration order wins and the rest never run. If nobody raises, the login is settled and you are signed in.

The client gets the prompt name and renders one component for it:

{ "prompt": "first-factor", "settled": false, "warnings": ["invalid-credentials"],
"identifier": "owner", "actor": { "nickname": "owner", "name": "Jon" } }
module LoginStates
class Password < LoginState
EXPIRY = 12.hours
accepts :password
handles "password" do
verify
end
def verify
return warn!("missing-identifier") if login.identifier.blank?
if (authenticated = Actor.authenticate(login.identifier, update(:password)))
login.actor = authenticated
factored! :first_factor, expiry: EXPIRY
else
warn! "invalid-credentials"
end
end
end
end
accepts the parameters this state will read, and the only ones it can
handles an event name from the client, and what to do with it
prompts a prompt name, and the condition under which it must be shown

All three inherit, so a subclass gets its parent’s and can add.

Verifying and insisting are different states

Section titled “Verifying and insisting are different states”

Notice that Password has no prompts block, and FirstFactor has nothing else:

class FirstFactor < LoginState
prompts "first-factor" do
!touched?(:first_factor)
end
end

One state knows how to check a password; a different one knows the flow cannot continue until some first factor has been satisfied. OneTimePassword and SecondFactor are the same pair a step later.

That split is what lets a second way of proving a first factor — WebAuthn, a login link, an upstream identity provider — be added without touching the thing that insists on one. It sets factored! :first_factor and FirstFactor stops raising.

None of those three is built yet. Sign-in today is a password, with a one-time password or a backup code as a second factor; the machine is the part that is finished.

factored! records a factor with a lifetime, and touched? is false again once it lapses:

factored! :first_factor, expiry: 12.hours

So a second factor pending for half a day does not leave a half-authenticated session lying around, and start_over! on any state expires what it owns rather than clearing the whole store blindly.

A LoginState and a prompt component. The state goes in app/logins/login_states/, its position in Login::STATES decides when it runs, and the component renders the prompt it names.

Prompts exist twice — an ERB partial and a Svelte component — because sign-in works without JavaScript. The server renders the partial and the bundle replaces it if it runs; both post the same events to the same endpoint, and LoginsController#update answers HTML or JSON from one code path.