diff --git a/CHANGELOG.md b/CHANGELOG.md index 936c72a..8dd524c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ ## Unreleased +- `prusactl login --manual` signs in without a password passing through the + terminal, so an AI agent can run it. It prints the Prusa Account link on its + own line, then reads the address the browser ends on + (`https://connect.prusa3d.com/login/auth-callback?code=...`) from stdin, so an + agent can keep stdin open, read the address from a browser tab, and write it + back. `--no-open` stops it starting a browser. A loopback mode doesn't exist + because Prusa Account rejects a localhost redirect for the Connect client + ("Mismatching redirect URI"). +- `prusactl login` without `--manual` says so when it has no terminal to ask for + a password on, rather than failing on the prompt. - `prusactl status` and the printer summary show how long a running print has left, how long it has run, and when it should finish on the local clock, for example `PRINTING 89%, 23m left (ends ~12:41), 2h10m elapsed, nozzle 250/250°C, diff --git a/README.md b/README.md index c88be06..7319031 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ runout or errors. Needs Connect. - **Run G-code:** any G-code, over the direct connection, while the printer is idle. -It is one Go binary with no browser involved. Setup is two terminal prompts. +It is one Go binary; no browser is needed. Setup is two terminal prompts. ## Things you can ask @@ -180,6 +180,19 @@ code if your account has one. It fills in Prusa's own login page over HTTPS the same way a browser would. It then trades the resulting code for tokens, using the OAuth + PKCE flow of the Connect web app. +**Letting an AI agent sign in for you.** `prusactl login --manual` never sees +a password. It prints a Prusa Account link on a line of its own (and opens it +in your browser unless you pass `--no-open`), then reads one line from stdin: +the address the browser ends on after you approve, which looks like +`https://connect.prusa3d.com/login/auth-callback?code=...&state=...` (the bare +`code=` value also works). An agent can drive it. +There is no loopback mode: Prusa Account rejects a +`127.0.0.1` redirect for the Connect client ("Mismatching redirect URI"), so +the code can only be read from the address bar. It also relies on Connect's +`auth-callback` page leaving a code alone when its `state` isn't one the page +issued, which is how the page behaves today. If Prusa changes that, the sign-in +fails at the token step, and the fix is an update to prusactl. + - **Your password** is sent only to account.prusa3d.com and is never stored. - **Where tokens are kept:** the OS keychain (macOS Keychain, Secret Service, or Windows Credential Manager), under the service `prusactl`. A machine without @@ -223,7 +236,7 @@ printer you can leave it out. `via: "direct"` or `via: "connect"` forces a route ```text prusactl setup [ADDRESS] connect directly to the printer on your network -prusactl login optional: sign in to Prusa Connect +prusactl login [--manual] optional: sign in to Prusa Connect prusactl logout forget the Prusa Connect session prusactl status printer state and how it is reachable prusactl mcp run the MCP server on stdio diff --git a/cmd/prusactl/help.go b/cmd/prusactl/help.go index 210a7f3..ee20809 100644 --- a/cmd/prusactl/help.go +++ b/cmd/prusactl/help.go @@ -62,10 +62,22 @@ Settings > Network > PrusaLink.`, { Name: "login", Summary: "Sign in to Prusa Connect (optional)", - Help: `Asks for your Prusa Account email, password, and two-factor code in the -terminal. The password goes only to account.prusa3d.com and is never saved; -the session is saved and renews itself. Prusa Connect adds remote access, the -camera, on-screen dialogs, the print queue, and history.`, + Args: "[flags]", + Help: `Signs in to Prusa Connect, which adds remote access, the camera, on-screen +dialogs, the print queue, and history. The session is saved and renews itself. + +By default it asks for your Prusa Account email, password, and two-factor code +in the terminal. The password goes only to account.prusa3d.com and is never +saved. + +--manual avoids the password: it prints a Prusa Account link on its own line of +stdout, you approve it in any browser, and you give it the address the browser +ends on (https://connect.prusa3d.com/login/auth-callback?code=...&state=...) as +one line on stdin. The address may also be just its code= value.`, + Flags: []flagSpec{ + {Name: "manual", Usage: "approve in a browser and give the final address on stdin"}, + {Name: "no-open", Usage: "only print the link; don't start a browser (with --manual)"}, + }, }, { Name: "logout", diff --git a/cmd/prusactl/main.go b/cmd/prusactl/main.go index dc76265..b8c5f5c 100644 --- a/cmd/prusactl/main.go +++ b/cmd/prusactl/main.go @@ -16,7 +16,9 @@ import ( "net/http" "net/url" "os" + "os/exec" "os/signal" + "runtime" "runtime/debug" "strings" "time" @@ -115,7 +117,14 @@ func run(ctx context.Context, args []string) error { lc, lcErr := link.Open() switch name { case "login": - return noArguments(cmd, rest, func() error { return login(ctx, session, cc, lc, lcErr) }) + opts, pos, err := cmd.parse(rest) + if err != nil { + return err + } + if len(pos) > 0 { + return fmt.Errorf("login: takes no arguments, got %q (see `prusactl help login`)", pos[0]) + } + return login(ctx, opts, session, cc, lc, lcErr) case "status": opts, pos, err := cmd.parse(rest) if err != nil { @@ -305,9 +314,30 @@ func (terminalPrompter) OneTimeCode() (string, error) { return ask("Two-factor code from your authenticator app: ") } -func login(ctx context.Context, session *auth.Session, cc *connect.Client, lc *link.Client, lcErr error) error { - fmt.Fprintln(os.Stderr, "Signing in to Prusa Connect with your Prusa Account.") - if _, err := session.Login(ctx, terminalPrompter{host: accountHost(session)}); err != nil { +func login(ctx context.Context, o options, session *auth.Session, cc *connect.Client, lc *link.Client, lcErr error) error { + if o.given("no-open") && !o.on("manual") { + return errors.New("login: --no-open only applies to --manual") + } + var err error + if o.on("manual") { + // The link goes to stdout on a line of its own, so a script or an + // agent can pick it out; everything around it goes to stderr. + _, err = session.LoginManual(ctx, func(u string) { + fmt.Fprintln(os.Stderr, "Open this link in a browser and approve the sign-in to Prusa Connect:") + fmt.Println(u) + if !o.on("no-open") && openBrowser(u) == nil { + fmt.Fprintln(os.Stderr, "(Opened it in your browser.)") + } + fmt.Fprintln(os.Stderr, "Then paste the address the browser ended on (https://connect.prusa3d.com/login/auth-callback?code=...) here and press Enter.") + }, func() (string, error) { return ask("> ") }) + } else { + if !term.IsTerminal(int(os.Stdin.Fd())) { + return errors.New("login: asking for a password needs an interactive terminal; use `prusactl login --manual` and write the final browser address to stdin (see `prusactl help login`)") + } + fmt.Fprintln(os.Stderr, "Signing in to Prusa Connect with your Prusa Account.") + _, err = session.Login(ctx, terminalPrompter{host: accountHost(session)}) + } + if err != nil { return err } if err := server.RegisterUser(ctx, cc); err != nil { @@ -317,6 +347,25 @@ func login(ctx context.Context, session *auth.Session, cc *connect.Client, lc *l return status(ctx, server.New(session, cc, lc, lcErr, buildVersion()), lc) } +// openBrowser asks the desktop to show u. It reports whether it could start a +// browser, not whether one appeared. +func openBrowser(u string) error { + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", u) + case "windows": + cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", u) + default: + cmd = exec.Command("xdg-open", u) + } + if err := cmd.Start(); err != nil { + return err + } + go cmd.Wait() + return nil +} + func status(ctx context.Context, srv *server.Server, lc *link.Client) error { st := srv.Status(ctx) direct, _ := st["direct"].(map[string]any) diff --git a/internal/auth/manual.go b/internal/auth/manual.go new file mode 100644 index 0000000..c520f6c --- /dev/null +++ b/internal/auth/manual.go @@ -0,0 +1,42 @@ +package auth + +import ( + "context" + "errors" + "fmt" + "strings" +) + +// ManualLogin signs in without a password passing through this process: +// announce gets the Prusa Account authorize URL, the user (or an agent driving +// a browser) approves it anywhere, and read returns what comes back, either the +// full URL the browser ended on or just its code. Prusa Account only accepts +// the Connect web app's own redirect for this client (it rejects a loopback +// one as "Mismatching redirect URI"), so the browser ends on +// https://connect.prusa3d.com/login/auth-callback with the code in the address +// bar, and nothing can listen for it. +func (c Config) ManualLogin(ctx context.Context, announce func(authURL string), read func() (string, error)) (*Token, error) { + p, err := newPKCE() + if err != nil { + return nil, err + } + announce(c.authorizeURL(p)) + pasted, err := read() + if err != nil { + return nil, fmt.Errorf("reading the pasted sign-in URL: %w", err) + } + pasted = strings.TrimSpace(pasted) + if pasted == "" { + return nil, errors.New("nothing was pasted") + } + code := pasted + if strings.ContainsAny(pasted, "?&=") { + if !strings.Contains(pasted, "://") { + pasted = "?" + strings.TrimPrefix(pasted, "?") + } + if code, err = c.codeFromCallback(pasted, p); err != nil { + return nil, err + } + } + return c.exchangeCode(ctx, code, p) +} diff --git a/internal/auth/manual_test.go b/internal/auth/manual_test.go new file mode 100644 index 0000000..3784b21 --- /dev/null +++ b/internal/auth/manual_test.go @@ -0,0 +1,61 @@ +package auth + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "net/url" + "testing" +) + +// fakeTokenServer answers /o/token/ and records the form it was sent. +func fakeTokenServer(t *testing.T, form *url.Values) Config { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/o/token/" { + http.NotFound(w, r) + return + } + r.ParseForm() + *form = r.PostForm + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, `{"access_token":"acc","refresh_token":"ref","expires_in":3600}`) + })) + t.Cleanup(srv.Close) + return Config{AccountURL: srv.URL, ClientID: "cid", RedirectURI: "https://app.example/cb", HTTP: srv.Client()} +} + +func TestManualLoginAcceptsAURLOrACode(t *testing.T) { + for name, paste := range map[string]func(state string) string{ + "url": func(s string) string { return "https://app.example/cb?code=pasted&state=" + s }, + "query": func(s string) string { return "code=pasted&state=" + s }, + "code": func(string) string { return "pasted" }, + } { + t.Run(name, func(t *testing.T) { + var form url.Values + c := fakeTokenServer(t, &form) + var state string + tok, err := c.ManualLogin(context.Background(), + func(u string) { au, _ := url.Parse(u); state = au.Query().Get("state") }, + func() (string, error) { return " " + paste(state) + "\n", nil }) + if err != nil || tok.AccessToken != "acc" { + t.Fatalf("got %+v, %v", tok, err) + } + if form.Get("code") != "pasted" || form.Get("redirect_uri") != "https://app.example/cb" { + t.Fatalf("token request %v", form) + } + }) + } +} + +func TestManualLoginRejectsBadPastes(t *testing.T) { + for _, paste := range []string{"", "https://app.example/cb?code=x&state=forged", "https://app.example/cb?state=s"} { + var form url.Values + c := fakeTokenServer(t, &form) + _, err := c.ManualLogin(context.Background(), func(string) {}, func() (string, error) { return paste, nil }) + if err == nil || form != nil { + t.Errorf("paste %q: err %v, redeemed %v", paste, err, form) + } + } +} diff --git a/internal/auth/session.go b/internal/auth/session.go index b032ccf..8835f8e 100644 --- a/internal/auth/session.go +++ b/internal/auth/session.go @@ -127,7 +127,17 @@ func (s *Session) Invalidate(access string) { // Login signs in with the user's Prusa Account credentials (see // PasswordLogin) and stores the resulting tokens. func (s *Session) Login(ctx context.Context, prompt Prompter) (*Token, error) { - tok, err := s.Config.PasswordLogin(ctx, prompt) + return s.save(s.Config.PasswordLogin(ctx, prompt)) +} + +// LoginManual signs in with a pasted redirect URL (see Config.ManualLogin) and +// stores the resulting tokens. +func (s *Session) LoginManual(ctx context.Context, announce func(authURL string), read func() (string, error)) (*Token, error) { + return s.save(s.Config.ManualLogin(ctx, announce, read)) +} + +// save stores the tokens a login produced. +func (s *Session) save(tok *Token, err error) (*Token, error) { if err != nil { return nil, err }