diff --git a/README.md b/README.md index 40aad9a..524d0bf 100644 --- a/README.md +++ b/README.md @@ -55,13 +55,13 @@ make clean # Remove build artifacts ```sh # 1. Authenticate with your AT Protocol handle -tng auth login --handle alice.bsky.social +tng auth login --handle example.bsky.social # 2. Create a repository on a knot tng repo create my-project --knot knot.example.com # 3. Clone it -tng repo clone alice.bsky.social/my-project +tng repo clone example.bsky.social/my-project # 4. Work on code, then create a pull request cd my-project @@ -79,14 +79,17 @@ tng browse ## Authentication -`tng` uses AT Protocol OAuth 2.0 with PKCE and DPoP. Your handle can be from any AT Protocol provider -- Bluesky (`alice.bsky.social`), Tangled (`alice.tngl.sh`), or any self-hosted PDS. +`tng` uses AT Protocol OAuth 2.0 with PKCE and DPoP. Your handle can be from any AT Protocol provider -- Bluesky (`example.bsky.social`), Tangled (`example.tngl.sh`), or any self-hosted PDS. ```sh # Interactive login (opens browser) tng auth login # Login with a specific handle -tng auth login --handle alice.bsky.social +tng auth login --handle example.bsky.social + +# Login from an SSH or other headless session +tng auth login --manual --handle example.bsky.social # Check who you're logged in as tng auth status @@ -97,6 +100,12 @@ tng auth logout Sessions are stored in `~/.config/tng/`. Tokens are persisted locally and refreshed automatically. The public client flow is used, so tokens expire after approximately two weeks. +When run over SSH, `tng auth login` automatically uses manual browser +authentication. Open the displayed authorization URL on your local machine, +authorize the CLI, then copy the full localhost callback URL from the browser's +address bar back into the terminal. Use `--manual` to select the same flow in +other headless environments. + ### Environment variables | Variable | Description | @@ -112,7 +121,7 @@ Sessions are stored in `~/.config/tng/`. Tokens are persisted locally and refres ### `tng auth` -- Authentication ```sh -tng auth login [--handle ] # Log in via browser OAuth +tng auth login [--handle ] [--manual] # Log in via browser OAuth tng auth logout [--did ] # Log out and revoke tokens tng auth status # Show authenticated accounts ``` diff --git a/internal/authflow/flow.go b/internal/authflow/flow.go index 4f64b50..21dd321 100644 --- a/internal/authflow/flow.go +++ b/internal/authflow/flow.go @@ -1,12 +1,15 @@ package authflow import ( + "bufio" "context" "fmt" "html" + "io" "net" "net/http" - "os" + "net/url" + "strings" "time" "github.com/bluesky-social/indigo/atproto/auth/oauth" @@ -18,6 +21,7 @@ import ( const ( callbackPath = "/callback" + loginTimeout = 5 * time.Minute ) // LoginResult contains the result of a successful login. @@ -27,14 +31,23 @@ type LoginResult struct { SessionID string } +// LoginOptions configures how the browser callback is completed. +type LoginOptions struct { + // ManualCallback prints the authorization URL and reads the final localhost + // callback URL from Input instead of waiting for a browser on this machine. + ManualCallback bool + Input io.Reader + Output io.Writer +} + // Login performs the full AT Protocol OAuth login flow: // 1. Resolves identity // 2. Sends PAR // 3. Opens browser for user authorization -// 4. Handles callback on local server +// 4. Handles callback on a local server or from a pasted callback URL // 5. Exchanges code for tokens // 6. Persists session -func Login(ctx context.Context, identifier string) (*LoginResult, error) { +func Login(ctx context.Context, identifier string, opts LoginOptions) (*LoginResult, error) { // Bind the callback port and keep the listener for the whole flow, so the // redirect URL registered with the auth server can't be claimed by another // process between port selection and serving. @@ -42,6 +55,7 @@ func Login(ctx context.Context, identifier string) (*LoginResult, error) { if err != nil { return nil, fmt.Errorf("failed to find available port: %w", err) } + defer listener.Close() port := listener.Addr().(*net.TCPAddr).Port callbackURL := fmt.Sprintf("http://127.0.0.1:%d%s", port, callbackPath) @@ -59,10 +73,137 @@ func Login(ctx context.Context, identifier string) (*LoginResult, error) { // Start the auth flow (resolves identity, sends PAR) redirectURL, err := app.StartAuthFlow(ctx, identifier) if err != nil { - listener.Close() return nil, fmt.Errorf("failed to start auth flow: %w", err) } + var sessData *oauth.ClientSessionData + if opts.ManualCallback { + sessData, err = completeManualCallback(ctx, app, callbackURL, redirectURL, opts) + } else { + sessData, err = completeLocalCallback(ctx, app, listener, redirectURL, opts.Output) + } + if err != nil { + return nil, err + } + + // Resolve handle for display + handle := resolveHandle(ctx, app, sessData.AccountDID) + + // Save account to index + if err := config.AddAccount(config.AccountInfo{ + DID: sessData.AccountDID.String(), + Handle: handle, + SessionID: sessData.SessionID, + }); err != nil { + return nil, fmt.Errorf("failed to save account info: %w", err) + } + + return &LoginResult{ + DID: sessData.AccountDID, + Handle: handle, + SessionID: sessData.SessionID, + }, nil +} + +func completeManualCallback( + ctx context.Context, + app *oauth.ClientApp, + callbackURL string, + redirectURL string, + opts LoginOptions, +) (*oauth.ClientSessionData, error) { + if opts.Input == nil { + return nil, fmt.Errorf("manual login requires terminal input") + } + output := opts.Output + if output == nil { + output = io.Discard + } + + fmt.Fprintf(output, `Open this URL in a browser: + + %s + +After authorizing, the browser will redirect to localhost and may show a +connection error. Copy the full URL from the browser's address bar and paste +it here. + +Callback URL: `, redirectURL) + + type inputResult struct { + value string + err error + } + inputCh := make(chan inputResult, 1) + go func() { + value, err := readLine(opts.Input) + inputCh <- inputResult{value: value, err: err} + }() + + var input inputResult + select { + case input = <-inputCh: + case <-time.After(loginTimeout): + return nil, fmt.Errorf("authentication timed out after 5 minutes") + case <-ctx.Done(): + return nil, ctx.Err() + } + if input.err != nil { + return nil, fmt.Errorf("failed to read callback URL: %w", input.err) + } + + params, err := parseCallbackURL(input.value, callbackURL) + if err != nil { + return nil, err + } + return app.ProcessCallback(ctx, params) +} + +func parseCallbackURL(value string, expectedCallbackURL string) (url.Values, error) { + value = strings.TrimSpace(value) + if value == "" { + return nil, fmt.Errorf("callback URL is required") + } + if strings.Contains(value, "#") { + return nil, fmt.Errorf("callback URL must not contain a fragment") + } + + callback, err := url.ParseRequestURI(value) + if err != nil { + return nil, fmt.Errorf("invalid callback URL: %w", err) + } + expected, err := url.Parse(expectedCallbackURL) + if err != nil { + return nil, fmt.Errorf("invalid expected callback URL: %w", err) + } + if callback.Scheme != expected.Scheme || + callback.Host != expected.Host || + callback.EscapedPath() != expected.EscapedPath() || + callback.User != nil || + callback.Fragment != "" { + return nil, fmt.Errorf("callback URL must start with %s", expectedCallbackURL) + } + if callback.RawQuery == "" { + return nil, fmt.Errorf("callback URL is missing OAuth parameters") + } + return callback.Query(), nil +} + +func readLine(r io.Reader) (string, error) { + line, err := bufio.NewReader(r).ReadString('\n') + if err != nil && err != io.EOF { + return "", err + } + return strings.TrimSpace(line), nil +} + +func completeLocalCallback( + ctx context.Context, + app *oauth.ClientApp, + listener net.Listener, + redirectURL string, + output io.Writer, +) (*oauth.ClientSessionData, error) { // Set up callback handling type callbackResult struct { sessData *oauth.ClientSessionData @@ -99,14 +240,17 @@ func Login(ctx context.Context, identifier string) (*LoginResult, error) { // Open browser if err := browser.Open(redirectURL); err != nil { - fmt.Fprintf(os.Stderr, "Could not open browser automatically.\nOpen this URL in your browser:\n\n %s\n\n", redirectURL) + if output == nil { + output = io.Discard + } + fmt.Fprintf(output, "Could not open browser automatically.\nOpen this URL in your browser:\n\n %s\n\n", redirectURL) } // Wait for callback (with timeout) var result callbackResult select { case result = <-resultCh: - case <-time.After(5 * time.Minute): + case <-time.After(loginTimeout): result = callbackResult{err: fmt.Errorf("authentication timed out after 5 minutes")} case <-ctx.Done(): result = callbackResult{err: ctx.Err()} @@ -120,24 +264,7 @@ func Login(ctx context.Context, identifier string) (*LoginResult, error) { if result.err != nil { return nil, result.err } - - // Resolve handle for display - handle := resolveHandle(ctx, app, result.sessData.AccountDID) - - // Save account to index - if err := config.AddAccount(config.AccountInfo{ - DID: result.sessData.AccountDID.String(), - Handle: handle, - SessionID: result.sessData.SessionID, - }); err != nil { - return nil, fmt.Errorf("failed to save account info: %w", err) - } - - return &LoginResult{ - DID: result.sessData.AccountDID, - Handle: handle, - SessionID: result.sessData.SessionID, - }, nil + return result.sessData, nil } // Logout revokes tokens and removes session data for an account. diff --git a/internal/authflow/flow_test.go b/internal/authflow/flow_test.go new file mode 100644 index 0000000..a437369 --- /dev/null +++ b/internal/authflow/flow_test.go @@ -0,0 +1,62 @@ +package authflow + +import ( + "net/url" + "strings" + "testing" +) + +func TestParseCallbackURL(t *testing.T) { + const expected = "http://127.0.0.1:49152/callback" + + tests := []struct { + name string + value string + want url.Values + wantError string + }{ + { + name: "successful callback", + value: expected + "?code=auth-code&state=oauth-state&iss=https%3A%2F%2Fpds.example.com", + want: url.Values{ + "code": {"auth-code"}, + "state": {"oauth-state"}, + "iss": {"https://pds.example.com"}, + }, + }, + { + name: "authorization error callback", + value: expected + "?error=access_denied&error_description=nope&state=oauth-state", + want: url.Values{ + "error": {"access_denied"}, + "error_description": {"nope"}, + "state": {"oauth-state"}, + }, + }, + {name: "empty", wantError: "callback URL is required"}, + {name: "not a URL", value: "not a url", wantError: "invalid callback URL"}, + {name: "wrong host", value: "http://localhost:49152/callback?state=x", wantError: "must start with"}, + {name: "wrong port", value: "http://127.0.0.1:49153/callback?state=x", wantError: "must start with"}, + {name: "wrong path", value: "http://127.0.0.1:49152/other?state=x", wantError: "must start with"}, + {name: "fragment", value: expected + "?state=x#fragment", wantError: "must not contain a fragment"}, + {name: "missing parameters", value: expected, wantError: "missing OAuth parameters"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseCallbackURL(tt.value, expected) + if tt.wantError != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantError) { + t.Fatalf("error = %v, want error containing %q", err, tt.wantError) + } + return + } + if err != nil { + t.Fatal(err) + } + if got.Encode() != tt.want.Encode() { + t.Errorf("params = %q, want %q", got.Encode(), tt.want.Encode()) + } + }) + } +} diff --git a/pkg/cmd/auth/login/login.go b/pkg/cmd/auth/login/login.go index 0dbd044..208ff89 100644 --- a/pkg/cmd/auth/login/login.go +++ b/pkg/cmd/auth/login/login.go @@ -1,7 +1,9 @@ package login import ( + "context" "fmt" + "os" "github.com/spf13/cobra" "tangled.org/eric.wien/tng-cli/internal/authflow" @@ -12,11 +14,16 @@ import ( type Options struct { IO *iostreams.IOStreams Handle string + Manual bool + Login func(context.Context, string, authflow.LoginOptions) (*authflow.LoginResult, error) + Remote func() bool } func NewCmdLogin(f *cmdutil.Factory) *cobra.Command { opts := &Options{ - IO: f.IOStreams, + IO: f.IOStreams, + Login: authflow.Login, + Remote: isSSHSession, } cmd := &cobra.Command{ @@ -24,10 +31,13 @@ func NewCmdLogin(f *cmdutil.Factory) *cobra.Command { Short: "Log in to Tangled", Long: `Authenticate with your Tangled account using AT Protocol OAuth. -Opens a browser window for you to authorize the CLI. Your handle can be -a Bluesky handle (e.g., alice.bsky.social) or a Tangled handle (e.g., alice.tngl.sh).`, +Opens a browser window for you to authorize the CLI. Over SSH, displays the +authorization URL and asks you to paste the localhost callback URL instead. +Your handle can be a Bluesky handle (e.g., alice.bsky.social) or a Tangled +handle (e.g., alice.tngl.sh).`, Example: ` tng auth login - tng auth login --handle alice.bsky.social`, + tng auth login --handle alice.bsky.social + tng auth login --manual --handle alice.bsky.social`, Args: cmdutil.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { return loginRun(cmd, opts) @@ -35,6 +45,7 @@ a Bluesky handle (e.g., alice.bsky.social) or a Tangled handle (e.g., alice.tngl } cmd.Flags().StringVar(&opts.Handle, "handle", "", "AT Protocol handle or DID to log in with") + cmd.Flags().BoolVar(&opts.Manual, "manual", false, "complete login by pasting the localhost callback URL") return cmd } @@ -60,9 +71,21 @@ func loginRun(cmd *cobra.Command, opts *Options) error { } fmt.Fprintf(opts.IO.ErrOut, "Logging in as %s...\n", handle) - fmt.Fprintf(opts.IO.ErrOut, "Waiting for authentication in browser...\n") + manual := opts.Manual + if opts.Remote != nil { + manual = manual || opts.Remote() + } + if manual { + fmt.Fprintln(opts.IO.ErrOut, "Using manual browser authentication...") + } else { + fmt.Fprintln(opts.IO.ErrOut, "Waiting for authentication in browser...") + } - result, err := authflow.Login(cmd.Context(), handle) + result, err := opts.Login(cmd.Context(), handle, authflow.LoginOptions{ + ManualCallback: manual, + Input: opts.IO.In, + Output: opts.IO.ErrOut, + }) if err != nil { return fmt.Errorf("login failed: %w", err) } @@ -76,3 +99,9 @@ func loginRun(cmd *cobra.Command, opts *Options) error { return nil } + +func isSSHSession() bool { + return os.Getenv("SSH_CONNECTION") != "" || + os.Getenv("SSH_CLIENT") != "" || + os.Getenv("SSH_TTY") != "" +} diff --git a/pkg/cmd/auth/login/login_test.go b/pkg/cmd/auth/login/login_test.go new file mode 100644 index 0000000..df9d8b6 --- /dev/null +++ b/pkg/cmd/auth/login/login_test.go @@ -0,0 +1,88 @@ +package login + +import ( + "bytes" + "context" + "io" + "strings" + "testing" + + "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/spf13/cobra" + "tangled.org/eric.wien/tng-cli/internal/authflow" + "tangled.org/eric.wien/tng-cli/pkg/iostreams" +) + +func TestLoginRunUsesManualCallbackOverSSH(t *testing.T) { + var loginOpts authflow.LoginOptions + errOut := &bytes.Buffer{} + ioStreams := iostreams.Test() + ioStreams.In = io.NopCloser(strings.NewReader("callback\n")) + ioStreams.ErrOut = errOut + + opts := &Options{ + IO: ioStreams, + Handle: "alice.example.com", + Remote: func() bool { return true }, + Login: func(_ context.Context, identifier string, opts authflow.LoginOptions) (*authflow.LoginResult, error) { + if identifier != "alice.example.com" { + t.Errorf("identifier = %q", identifier) + } + loginOpts = opts + return &authflow.LoginResult{ + DID: syntax.DID("did:plc:alice"), + Handle: "alice.example.com", + }, nil + }, + } + + if err := loginRun(&cobra.Command{}, opts); err != nil { + t.Fatal(err) + } + if !loginOpts.ManualCallback { + t.Error("manual callback was not enabled for SSH session") + } + if loginOpts.Input != ioStreams.In || loginOpts.Output != ioStreams.ErrOut { + t.Error("login did not receive command IO streams") + } + if !strings.Contains(errOut.String(), "Using manual browser authentication") { + t.Errorf("output = %q", errOut.String()) + } +} + +func TestLoginRunManualFlag(t *testing.T) { + var manual bool + ioStreams := iostreams.Test() + + opts := &Options{ + IO: ioStreams, + Handle: "alice.example.com", + Manual: true, + Remote: func() bool { return false }, + Login: func(_ context.Context, _ string, opts authflow.LoginOptions) (*authflow.LoginResult, error) { + manual = opts.ManualCallback + return &authflow.LoginResult{DID: syntax.DID("did:plc:alice")}, nil + }, + } + + if err := loginRun(&cobra.Command{}, opts); err != nil { + t.Fatal(err) + } + if !manual { + t.Error("manual callback was not enabled by --manual") + } +} + +func TestIsSSHSession(t *testing.T) { + for _, name := range []string{"SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY"} { + t.Setenv(name, "") + } + if isSSHSession() { + t.Fatal("isSSHSession() = true without SSH environment") + } + + t.Setenv("SSH_CONNECTION", "client 123 server 22") + if !isSSHSession() { + t.Fatal("isSSHSession() = false with SSH_CONNECTION") + } +}