From a8157fe8cb861482771809223cffbc807f461f20 Mon Sep 17 00:00:00 2001 From: geobelsky Date: Sat, 21 Mar 2026 21:13:50 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20magic=20link=20auto-login=20=E2=80=94?= =?UTF-8?q?=20parallel=20OTP=20prompt=20+=20grant=20polling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When user clicks the magic link in email, CLI detects it automatically via polling GET /v1/auth/cli-grants/{grant_id} every 2s. No need to type the OTP code — whichever method completes first wins. Also updated prompt text to mention the magic link option. Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/axme/email_login.go | 114 +++++++++++++++++++++++++++++----------- 1 file changed, 83 insertions(+), 31 deletions(-) diff --git a/cmd/axme/email_login.go b/cmd/axme/email_login.go index 8825b33..6fc0eb0 100644 --- a/cmd/axme/email_login.go +++ b/cmd/axme/email_login.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "strings" + "time" ) // runEmailLogin implements the email-first passwordless login flow: @@ -41,6 +42,7 @@ func (rt *runtime) runEmailLogin(ctx context.Context, ctxName string) error { } intentID := asString(body["intent_id"]) + grantID := asString(body["grant_id"]) expiresIn := int(asFloat(body["expires_in"])) if expiresIn <= 0 { expiresIn = 300 @@ -50,49 +52,99 @@ func (rt *runtime) runEmailLogin(ctx context.Context, ctxName string) error { _ = rt.printJSON(map[string]any{ "ok": true, "intent_id": intentID, - "message": "check your email for a 6-digit code and enter it at the prompt", + "message": "check your email for a 6-digit code or click the magic link", }) } else { fmt.Fprintln(os.Stderr, " Code sent! Check your inbox.") fmt.Fprintln(os.Stderr) - fmt.Fprintf(os.Stderr, " The code expires in %ds. Enter it below.\n", expiresIn) + fmt.Fprintf(os.Stderr, " Enter the 6-digit code below, or click the magic link in the email.\n") + fmt.Fprintf(os.Stderr, " The code expires in %ds.\n", expiresIn) fmt.Fprintln(os.Stderr) } - // Prompt for OTP code - otp, err := rt.promptOTP() - if err != nil { - return err - } + // Run OTP prompt and magic link polling in parallel. + // Whichever completes first wins. + type loginResult struct { + body map[string]any + err error + } + resultCh := make(chan loginResult, 2) + pollCtx, pollCancel := context.WithCancel(ctx) + defer pollCancel() - if !rt.outputJSON { - fmt.Fprintln(os.Stderr) - fmt.Fprintln(os.Stderr, " Verifying code...") + // Goroutine 1: poll grant status (magic link path) + if grantID != "" { + go func() { + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + for { + select { + case <-pollCtx.Done(): + return + case <-ticker.C: + pollStatus, pollBody, _, pollErr := rt.request(pollCtx, c, "GET", + "/v1/auth/cli-grants/"+grantID, nil, nil, true) + if pollErr != nil { + continue + } + if pollStatus == 200 && asString(pollBody["state"]) == "approved" { + resultCh <- loginResult{body: pollBody} + return + } + } + } + }() } - // Verify OTP - verifyStatus, verifyBody, verifyRaw, verifyErr := rt.request( - ctx, c, - "POST", "/v1/auth/login-intent/"+intentID+"/verify", - nil, map[string]any{"code": otp}, true, - ) - if verifyErr != nil { - return fmt.Errorf("login: could not verify code: %w", verifyErr) - } - switch verifyStatus { - case 410: - return fmt.Errorf("login: the sign-in code has expired — run `axme login` again") - case 422: - detail := asString(verifyBody["detail"]) - if detail == "" { - detail = string(verifyRaw) + // Goroutine 2: OTP prompt (blocking stdin read) + go func() { + otp, otpErr := rt.promptOTP() + if otpErr != nil { + resultCh <- loginResult{err: otpErr} + return } - return fmt.Errorf("login: invalid code — %s", detail) - case 409: - return fmt.Errorf("login: code already used — run `axme login` again") + verifyStatus, verifyBody, verifyRaw, verifyErr := rt.request( + ctx, c, + "POST", "/v1/auth/login-intent/"+intentID+"/verify", + nil, map[string]any{"code": otp}, true, + ) + if verifyErr != nil { + resultCh <- loginResult{err: fmt.Errorf("login: could not verify code: %w", verifyErr)} + return + } + switch verifyStatus { + case 410: + resultCh <- loginResult{err: fmt.Errorf("login: the sign-in code has expired — run `axme login` again")} + return + case 422: + detail := asString(verifyBody["detail"]) + if detail == "" { + detail = string(verifyRaw) + } + resultCh <- loginResult{err: fmt.Errorf("login: invalid code — %s", detail)} + return + case 409: + resultCh <- loginResult{err: fmt.Errorf("login: code already used — run `axme login` again")} + return + } + if verifyStatus >= 400 { + resultCh <- loginResult{err: fmt.Errorf("login: verification failed (%d): %s", verifyStatus, verifyRaw)} + return + } + resultCh <- loginResult{body: verifyBody} + }() + + // Wait for first successful result + res := <-resultCh + pollCancel() // stop grant polling + if res.err != nil { + return res.err } - if verifyStatus >= 400 { - return fmt.Errorf("login: verification failed (%d): %s", verifyStatus, verifyRaw) + verifyBody := res.body + + if !rt.outputJSON { + fmt.Fprintln(os.Stderr) + fmt.Fprintln(os.Stderr, " Sign-in successful!") } retrievedKey := asString(verifyBody["api_key"])