Repository navigation
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
Draft
sre-helmcode wants to merge 14 commits into
sre-helmcode wants to merge 14 commits into
Conversation
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>
sre-helmcode
force-pushed
the
feat/mfa
branch
from
October 23, 2026 17:36
d3c78b1 to
51cf584
Compare
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Two-factor sign-in for
nan auth loginand the panel (phase A3 of the MFA change, helmcode/nan-cloud-api#389). Built against the API contract in nan-cloud-apiopenspec/changes/mfa/api-contract.md("CLI contract" andPOST /api/auth/mfa/challenge).internal/auth: the verify POST now sendsmfa=1. When the platform answers with the__Host-nan_mfa_pendingcookie andreason=mfa_required,ExchangeTokenreturns*MFARequiredError. ItsCompleteposts the code to/api/auth/mfa/challengeas JSON, with the cookie and withoutOriginorSec-Fetch-Site. Six digits are sent ascode. Sixteen RFC 4648 base32 characters (A-Z, 2-7), in any case and with or without hyphens, are sent as a canonicalrecovery_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, andRetryablesays whether the same challenge accepts another answer. Only a wrong, replayed or unreadable code can be retried.nan auth login: promptsAuthenticator code:without echo on a terminal. A ctrl-c at that prompt, or at the--api-tokenprompt, 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.mfa_enabled_update_nan_climaps to a sentence, although a platform that follows the contract never sends it to this build.reason=finish_onboarding_in_the_browserand 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.internal/tui.Versiongoes from 0.1.34 to 0.1.35. README documents two-factor sign-in.mainthrough build: go 1.26.10 for GO-2026-6809 #61; this branch is rebased on it and no longer carries that commit.Security notes
session.json, never printed, and never logged.MFARequiredErrorimplementsFormat, so no fmt verb, slog handler orjson.Marshalprints it. Tests cover this, and they walk$HOMEafter both successful and failed sign-ins.--totpnever 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 withread -rsor 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.^[a-z0-9_]+$). A proxy's error page can't put arbitrary bytes on the member's terminal.auth.SignInBaseURLis now the single base for the three sign-in calls (link request, exchange and challenge), and only for those:internal/apistays 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 withHOME=$(mktemp -d), because it writes the dev session tosession.json. No flag or environment variable in the released binary can change it.Tests
internal/auth/authtestis 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:--totp -without a TTY: right, wrong (one try) and empty--totp -with a TTY on stdin: prompts without echo, one tryscript)Origin,Sec-Fetch-SiteorReferersentGuards were checked by inversion: breaking
mfa=1, addingOrigin, sendingtext/plain, removingFormat, retrying from a pipe or accepting--totp <code>each makes a test fail.Gate reproduced locally from
.github/workflows/ci.yml:gofmt -l .cleango vet ./...passesgo test -race -shuffle=on ./...passes, re-run several timesgovulncheck@v1.8.0reports no vulnerabilitiesThe PowerShell and installer jobs are untouched by this change.
Dependencies and merge order
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, somfa=1is ignored. The challenge path only triggers once the API creates challenges.mainpublishes a release.tag.ymlruns when CI passes onmain. Ifinternal/tui.Version(0.1.35) has no tag yet, it tags the commit and callsrelease.yml, which builds, attests and publishesv0.1.35, and the installers start serving it. Only the user merges, and the release is not published without the user's decision.port/charm-v2) replacescmd/auth.go,internal/authandinternal/tui/tui.go. If it merges first, this change has to be ported tointernal/cli/auth.goandinternal/api/auth.go.QA against dev (A4)
-ldflags -Xline above and run it withHOME=$(mktemp -d). Sign in as a member without a factor: the session is saved, and there is no prompt.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).--totp -from a pipe, and run without a TTY or flag to see the message.nan, thens).mfa enabled update nan cli.🤖 Generated with Claude Code