Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
17 changes: 15 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
20 changes: 16 additions & 4 deletions cmd/prusactl/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
57 changes: 53 additions & 4 deletions cmd/prusactl/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ import (
"net/http"
"net/url"
"os"
"os/exec"
"os/signal"
"runtime"
"runtime/debug"
"strings"
"time"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
Expand Down
42 changes: 42 additions & 0 deletions internal/auth/manual.go
Original file line number Diff line number Diff line change
@@ -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)
}
61 changes: 61 additions & 0 deletions internal/auth/manual_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
12 changes: 11 additions & 1 deletion internal/auth/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Loading