Skip to content

feat(auth): two-factor sign-in for nan auth login and the panel (0.1.35) - #60

Draft
sre-helmcode wants to merge 14 commits into
mainfrom
feat/mfa
Draft

sre-helmcode wants to merge 14 commits into
mainfrom
feat/mfa

Conversation

@sre-helmcode

@sre-helmcode sre-helmcode commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Two-factor sign-in for nan auth login and the panel (phase A3 of the MFA change, helmcode/nan-cloud-api#389). Built against the API contract in nan-cloud-api openspec/changes/mfa/api-contract.md ("CLI contract" and POST /api/auth/mfa/challenge).

  • internal/auth: the verify POST now sends mfa=1. When the platform answers with the __Host-nan_mfa_pending cookie and reason=mfa_required, ExchangeToken returns *MFARequiredError. Its Complete posts the code to /api/auth/mfa/challenge as JSON, with the cookie and without Origin or Sec-Fetch-Site. Six digits are sent as code. Sixteen RFC 4648 base32 characters (A-Z, 2-7), in any case and with or without hyphens, are sent as a canonical recovery_code. This is the alphabet the API makes recovery codes from. Anything else is refused locally, so it does not use up an attempt. Every refusal in the contract maps to a sentence, and Retryable says whether the same challenge accepts another answer. Only a wrong, replayed or unreadable code can be retried.
  • nan auth login: prompts Authenticator code: without echo on a terminal. A ctrl-c at that prompt, or at the --api-token prompt, now restores the terminal before exiting, where it used to leave the shell without echo. It prompts again after a wrong, replayed or unreadable code, until the platform accepts one or ends the sign-in. --totp - reads one line from stdin and allows a single try; if stdin is a terminal, it still prompts there without echo. Without a terminal and without the flag, the command explains that the link is spent and names the flag. After a recovery code, it prints how many are left and where to make new ones.
  • TUI: the panel asks the same question as a third step after the link. If the sign-in can't continue, it goes back to the email field with the address already filled in.
  • Old CLIs: mfa_enabled_update_nan_cli maps to a sentence, although a platform that follows the contract never sends it to this build.
  • Unfinished sign-up: an invited or registered member with a factor gets reason=finish_onboarding_in_the_browser and no cookie (contract, "CLI contract" step 8). It maps to a sentence that says the link is spent, where to ask for a new one and what comes after. A reason the CLI does not know is echoed only when it has the platform's shape (^[a-z0-9_]{1,64}$); anything else, such as escape sequences or long text, gets a generic sentence.
  • Version: internal/tui.Version goes from 0.1.34 to 0.1.35. README documents two-factor sign-in.
  • Toolchain: go 1.26.10 (GO-2026-6809) is already on main through build: go 1.26.10 for GO-2026-6809 #61; this branch is rebased on it and no longer carries that commit.

Security notes

  • The challenge cookie is kept in memory only. It is never written to session.json, never printed, and never logged. MFARequiredError implements Format, so no fmt verb, slog handler or json.Marshal prints it. Tests cover this, and they walk $HOME after both successful and failed sign-ins.
  • --totp never takes the code as an argument: its only value is -, because a recovery code typed on the command line would end up in shell history. The examples (help, error hint, README) read the code with read -rs or redirect it from a file; a test fails if one puts a code on the command line again. Combinations that can't work (--link -, --token, --api-token) are refused before anything is posted, so a wrong flag never spends a link.
  • An error code from the response is echoed only when it has the platform's shape (^[a-z0-9_]+$). A proxy's error page can't put arbitrary bytes on the member's terminal.
  • auth.SignInBaseURL is now the single base for the three sign-in calls (link request, exchange and challenge), and only for those: internal/api stays on production. Tests point it at a fake, and a QA build can point it at dev at build time (-ldflags "-X github.com/nxssie/nan-cli/internal/auth.SignInBaseURL=https://cloud-api-dev.nan.builders"). Run a QA build with HOME=$(mktemp -d), because it writes the dev session to session.json. No flag or environment variable in the released binary can change it.

Tests

internal/auth/authtest is a fake platform written from the contract: the verify table, challenge states, attempt counting, anti-replay, lock, origin and content-type rules. It is imported by tests only. The tests cover:

  • no factor (unchanged behavior)
  • right code
  • wrong code, then right code
  • recovery code, including the count left and the last one
  • used recovery code and replayed code
  • fifth wrong code
  • expired challenge
  • locked account
  • every refusal code in the contract, plus unknown codes, a non-JSON body, a plain 404 and a 5xx
  • recovery codes containing 0, 1, 8 or 9, which are refused locally and never sent
  • --totp - without a TTY: right, wrong (one try) and empty
  • --totp - with a TTY on stdin: prompts without echo, one try
  • ctrl-c at a hidden prompt restores the terminal before exiting, and restores the state that was saved, on the same descriptor (unix; also checked by hand in a pty with script)
  • the challenge POST does not follow a 302, 303, 307 or 308: the server it points at gets no request, so neither the cookie nor the code reaches it
  • no TTY and no flag
  • flag combinations
  • no Origin, Sec-Fetch-Site or Referer sent
  • the cookie never reaches disk, output or logs
  • the TUI stages, late challenges after esc, and the recovery notice

Guards were checked by inversion: breaking mfa=1, adding Origin, sending text/plain, removing Format, retrying from a pipe or accepting --totp <code> each makes a test fail.

Gate reproduced locally from .github/workflows/ci.yml:

  • gofmt -l . clean
  • go vet ./... passes
  • go test -race -shuffle=on ./... passes, re-run several times
  • cross-compiles for linux, darwin and windows on amd64 and arm64
  • govulncheck@v1.8.0 reports no vulnerabilities

The PowerShell and installer jobs are untouched by this change.

Dependencies and merge order

  • Depends on the API change (helmcode/nan-cloud-api, branch feat/mfa; PR not opened yet). Until the API ships MFA, this build behaves exactly like 0.1.34. Today's API reads only the form fields it knows, so mfa=1 is ignored. The challenge path only triggers once the API creates challenges.
  • Merging this PR to main publishes a release. tag.yml runs when CI passes on main. If internal/tui.Version (0.1.35) has no tag yet, it tags the commit and calls release.yml, which builds, attests and publishes v0.1.35, and the installers start serving it. Only the user merges, and the release is not published without the user's decision.
  • feat(ephemeral): nan run --ephemeral, nan ephemeral usage/settings #58 (draft) also bumps to 0.1.35. Whichever of the two merges second needs 0.1.36.
  • refactor!: rebuild on the Helmcode CLI structure #49 (port/charm-v2) replaces cmd/auth.go, internal/auth and internal/tui/tui.go. If it merges first, this change has to be ported to internal/cli/auth.go and internal/api/auth.go.

QA against dev (A4)

  1. Build with the -ldflags -X line above and run it with HOME=$(mktemp -d). Sign in as a member without a factor: the session is saved, and there is no prompt.
  2. Enroll TOTP in the dev UI, then sign in again: the CLI asks for Authenticator code:. Try a wrong code (attempts left), then the right one. Try the same code twice in a new sign-in (wait for the next one).
  3. Sign in with a recovery code: check the count left. Try --totp - from a pipe, and run without a TTY or flag to see the message.
  4. Wait 10 minutes before typing the code: the sign-in times out. Do the same in the panel (nan, then s).
  5. Check an old binary (0.1.34) against dev with a factor: mfa enabled update nan cli.

🤖 Generated with Claude Code

Pau Morillas and others added 12 commits October 23, 2026 19:31
ExchangeToken now posts mfa=1 with the link, telling the platform this
build can answer a challenge. For an account with a confirmed factor the
platform answers with the __Host-nan_mfa_pending cookie and
reason=mfa_required; ExchangeToken returns that as *MFARequiredError,
and Complete posts the code (6 digits) or recovery code (16 characters,
any case, hyphens optional) to /api/auth/mfa/challenge as JSON, with the
cookie and without Origin or Sec-Fetch-Site, and returns the session.

Every refusal in the API contract maps to a sentence; Retryable says
whether the same challenge can take another answer (wrong, replayed or
unreadable code, and mfa_totp_suspended for a recovery code). The
challenge cookie stays in memory: the error's Format never prints it,
and an error code is only echoed when it has the platform's shape.

The sign-in calls derive from one auth.Origin, so tests point the whole
flow at a fake and a QA build can point it at dev with -ldflags -X.
internal/auth/authtest is that fake, written from the contract.

Refs helmcode/nan-cloud-api#389

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When the link exchange comes back with a challenge, nan auth login asks
"Authenticator code:" without echo on a terminal, and asks again after a
wrong, replayed or unreadable code until the platform takes one or ends
the sign-in. --totp - reads one line of stdin instead, with one try,
for a shell that cannot answer a prompt; with neither, the command says
the link is spent and names the flag. A recovery code that gets the
member in prints how many are left and where to make new ones.

--totp takes only "-" (a recovery code on the command line would sit in
the shell history), and is refused next to --link -, --token and
--api-token before anything is posted, so a flag mistake never spends a
link. The session is saved as before, and the challenge cookie is never
written: the tests walk HOME for it after success and failure.

Refs helmcode/nan-cloud-api#389

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The panel shares internal/auth with nan auth login, so a link that
opens a challenge now leads to a third question, "Authenticator code:",
in the same step of the guided setup and without echo. A wrong,
replayed or unreadable code keeps the question, empty, with the attempts
left. A sign-in that cannot go on (timed out, exhausted, locked,
refused) spent its link, so the panel goes back to the address, filled
in, and enter asks for a new one. Esc forgets the challenge, and an
exchange that answers after the member left does not reopen it.

A recovery code that gets the member in is said on the next step, with
how many are left and where to make new ones.

Refs helmcode/nan-cloud-api#389

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Merging this to main publishes v0.1.35: tag.yml tags any commit on main
whose internal/tui.Version has no tag yet, and calls release.yml.

Refs helmcode/nan-cloud-api#389

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Recovery codes are 16 characters of the RFC 4648 base32 alphabet (A-Z,
2-7), which is what the platform makes them from and all it accepts
(nan-cloud-api internal/mfa/recovery.go NormalizeRecoveryCode). The local
check took any 16 letters and digits, so a 0, 1, 8 or 9 typo was posted
and came back as invalid_request instead of being refused here.

Refs helmcode/nan-cloud-api#389

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The user accepted OQ-11 (escalating lock, capped at 24 h), so the
100-failure suspension and its mfa_totp_suspended code were removed from
the API contract (nan-cloud-api 980a2ec) and are not implemented. The
refusal mapping is now exactly the contract's; an unknown code still
gets said, and ends the sign-in.

Refs helmcode/nan-cloud-api#389

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
auth.Origin read like the Origin header, which this same package takes
care never to send. Renamed to SignInBaseURL, and its comment now says
what it reaches: the link request, the exchange and the challenge, and
nothing else - internal/api (nan me, logout, runs, the panel's tabs)
stays on production. So a QA build pointed at dev has to run with a
HOME of its own: it writes the dev session over the member's real one,
and every command after the sign-in sends it to production.

Refs helmcode/nan-cloud-api#389

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With --totp - the code gets a single try, and when stdin is a terminal
it is still read there without echo rather than echoed back. Said in
nan auth login --help and the README, and pinned by a test.

Refs helmcode/nan-cloud-api#389

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
term.ReadPassword turns echo off and only restores it when it returns;
ctrl-c in between killed the process first and left the shell without
echo. While "Authenticator code:" or the --api-token prompt is up, the
interrupt is now caught: the terminal state is restored, then the
command exits 130 as an interrupted one does.

Checked in a pty with script(1): after ctrl-c at the --api-token
prompt, a build of main leaves the terminal at -echo, this one at echo.

Refs helmcode/nan-cloud-api#389

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`echo <code> | nan auth login ... --totp -` put the code in the shell
history all the same, which is what --totp only taking "-" was meant to
prevent. The help, the hint the errors print and the README now read it
into a variable without echo (read -rs) or redirect it from a file or a
password manager, and the promise says what holds: --totp never takes
the code as an argument. A test fails if any --totp - example puts a
code on the command line again.

Refs helmcode/nan-cloud-api#389

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The client refused redirects with nothing to pin it. A 302, 303, 307 or
308 from the challenge route now gets a test: the server it points at
answers like a successful challenge, and must get no request. With the
guard removed it gets the __Host-nan_mfa_pending cookie (Go forwards
Cookie to the same host on another port) and, on a 307 or 308, the code
in the replayed body, and the CLI signs in with its session. The reason
is now written next to the guard.

Refs helmcode/nan-cloud-api#389

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Nothing checked that readHiddenLine hands term.Restore, with the state
term.GetState saved, to the interrupt handler. The three terminal calls
are now package variables, so a test stands in for a terminal it does
not have: the stand-in ReadPassword sends SIGINT to the process, and the
test asserts Restore ran on the same descriptor with the saved state
before the command exited. Removing the restore, restoring another
state, or dropping the handler each fails it.

Refs helmcode/nan-cloud-api#389

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Pau Morillas and others added 2 commits October 23, 2026 20:47
An invited or registered member with a factor who signs in from the CLI
now gets a 302 to /access-denied?reason=finish_onboarding_in_the_browser
and no cookie at all (API contract, "CLI contract" step 8): the
onboarding token is behind the second factor, and that flow is browser
only. The fallback printed "the link did not work: finish onboarding in
the browser", which leaves out that the link is spent. It is now mapped
to a sentence that says where to ask for a new one and what comes after.

The fake platform answers both onboarding rows: with a factor and
client=cli, the reason and no cookie; without a factor, the unchanged
nan_onboarding token, which the CLI does not take for a session. Tests
pin the text in nan auth login and in the panel, and that no code is
asked for.

Refs helmcode/nan-cloud-api#389

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The reason of a refused link comes out of a redirect's query, decoded,
and an unknown one was printed with its underscores turned to spaces:
an escape sequence, a new line or a paragraph in it reached the
terminal as is. It is now echoed only when it has the platform's shape,
^[a-z0-9_]{1,64}$ - the filter the challenge's error codes already go
through - and anything else gets a sentence of our own. Tested through
ExchangeToken with escapes, a new line, capitals, spaces, 65 characters
and non-ASCII, and at the 64-character edge.

Refs helmcode/nan-cloud-api#389

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants