diff --git a/README.md b/README.md index c60f8f4..37d68d8 100644 --- a/README.md +++ b/README.md @@ -75,10 +75,23 @@ export PATH="$HOME/bin:$PATH" ```sh liftoff-export auth login # Log in to Liftoff +liftoff-export auth import # Save a refresh token captured from the app liftoff-export auth logout # Remove stored auth tokens liftoff-export auth refresh # Manually refresh the access token ``` +**Google Sign-In accounts** have no password, so `auth login` can't be used. Log +in on the phone app with an HTTPS proxy (mitmproxy, Proxyman) running, copy the +`refreshToken` from the `user.signIn` response, and hand it to `auth import`: + +```sh +liftoff-export auth import --refresh-token "$RT" # verifies + saves +echo "$RT" | liftoff-export auth import # same, token on stdin +``` + +For CI or containers, set `LIFTOFF_REFRESH_TOKEN` instead and skip the token file +entirely. + ### Workouts ```sh diff --git a/cmd/auth.go b/cmd/auth.go index b004b4d..8d65ffb 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -43,6 +43,81 @@ var loginCmd = &cobra.Command{ }, } +var ( + importRefreshTokenFlag string + importAccessTokenFlag string + importExpiresAtFlag string + importNoVerifyFlag bool +) + +var importCmd = &cobra.Command{ + Use: "import", + Short: "Import a refresh token captured from the Liftoff app", + Long: `Import a Liftoff refresh token and save it to +~/.config/liftoff-export/auth.json, the same place 'auth login' writes. + +This is the login path for accounts that have no password to type — +Google Sign-In accounts can't use 'auth login' at all (#55). Liftoff has +no web login, so the refresh token has to be read off the phone app with +an HTTPS proxy such as mitmproxy or Proxyman: log in the app, then look +for the POST to '.../api/trpc/user.signIn' and copy the 'refreshToken' +out of its JSON response. + +By default the token is exchanged for an access token immediately, which +both verifies it and fills in the expiry. Pass the token on --refresh-token +or on stdin: + + liftoff-export auth import --refresh-token "$RT" + echo "$RT" | liftoff-export auth import + +With --no-verify the CLI writes what you give it and makes no network +call; --access-token and --expires-at (RFC3339) are then required too. +Headless callers that already have a refresh token don't need this +command at all — set LIFTOFF_REFRESH_TOKEN and skip the token file.`, + RunE: func(cmd *cobra.Command, args []string) error { + rt := strings.TrimSpace(importRefreshTokenFlag) + if rt == "" { + fmt.Fprint(cmd.ErrOrStderr(), "Refresh token: ") + scanner := bufio.NewScanner(cmd.InOrStdin()) + if !scanner.Scan() { + if err := scanner.Err(); err != nil { + return err + } + } else { + rt = strings.TrimSpace(scanner.Text()) + } + } + if rt == "" { + return fmt.Errorf("a refresh token is required (--refresh-token or stdin)") + } + + if importNoVerifyFlag { + at := strings.TrimSpace(importAccessTokenFlag) + exp := strings.TrimSpace(importExpiresAtFlag) + if at == "" || exp == "" { + return fmt.Errorf("--no-verify requires --access-token and --expires-at") + } + if _, err := time.Parse(time.RFC3339Nano, exp); err != nil { + return fmt.Errorf("--expires-at must be RFC3339 (e.g. 2026-01-02T15:04:05Z): %w", err) + } + if err := auth.SaveFromCapture(at, rt, exp); err != nil { + return err + } + fmt.Fprintln(cmd.OutOrStdout(), "Imported (unverified). Tokens saved to ~/.config/liftoff-export/auth.json") + return nil + } + + store, err := auth.Refresh(rt) + if err != nil { + return fmt.Errorf("refresh token rejected: %w", err) + } + fmt.Fprintf(cmd.OutOrStdout(), + "Imported. Tokens saved to ~/.config/liftoff-export/auth.json (token expires %s)\n", + store.ExpiresAt.Local().Format(time.RFC3339)) + return nil + }, +} + var refreshCmd = &cobra.Command{ Use: "refresh", Short: "Manually refresh the access token", @@ -107,7 +182,13 @@ https://github.com/quantcli/common/blob/main/CONTRACT.md#5-auth`, func init() { authCmd.AddCommand(loginCmd) + authCmd.AddCommand(importCmd) authCmd.AddCommand(logoutCmd) authCmd.AddCommand(refreshCmd) authCmd.AddCommand(statusCmd) + + importCmd.Flags().StringVar(&importRefreshTokenFlag, "refresh-token", "", "refresh token (read from stdin if omitted)") + importCmd.Flags().StringVar(&importAccessTokenFlag, "access-token", "", "access token (only with --no-verify)") + importCmd.Flags().StringVar(&importExpiresAtFlag, "expires-at", "", "access-token expiry, RFC3339 (only with --no-verify)") + importCmd.Flags().BoolVar(&importNoVerifyFlag, "no-verify", false, "save tokens without exchanging them (no network call)") } diff --git a/cmd/auth_test.go b/cmd/auth_test.go index b7b3fb3..ea9fee5 100644 --- a/cmd/auth_test.go +++ b/cmd/auth_test.go @@ -4,6 +4,8 @@ import ( "bytes" "strings" "testing" + + "github.com/quantcli/liftoff-export-cli/internal/auth" ) // With LIFTOFF_REFRESH_TOKEN set, 'auth status' reports the headless source @@ -25,6 +27,69 @@ func TestAuthStatus_HeadlessEnvWins(t *testing.T) { } } +// 'auth import --no-verify' writes the captured tokens straight to the +// token file with no network call, so a Google Sign-In account that has +// proxy-captured its tokens gets a working login. (#55) +func TestAuthImport_NoVerifyWritesTokenFile(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Cleanup(resetImportFlags) + + importRefreshTokenFlag = "rt-captured" + importAccessTokenFlag = "at-captured" + importExpiresAtFlag = "2099-01-02T15:04:05Z" + importNoVerifyFlag = true + + var out bytes.Buffer + importCmd.SetOut(&out) + t.Cleanup(func() { importCmd.SetOut(nil) }) + + if err := importCmd.RunE(importCmd, nil); err != nil { + t.Fatalf("import --no-verify should succeed, got: %v", err) + } + + store, err := auth.Load() + if err != nil { + t.Fatalf("token file should be readable after import: %v", err) + } + if store.RefreshToken != "rt-captured" || store.AccessToken != "at-captured" { + t.Errorf("token file did not round-trip the captured tokens: %+v", store) + } +} + +// --no-verify without the companion values is a flag error, not a silent +// half-written token file. +func TestAuthImport_NoVerifyRequiresCompanions(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Cleanup(resetImportFlags) + + importRefreshTokenFlag = "rt-only" + importNoVerifyFlag = true + + if err := importCmd.RunE(importCmd, nil); err == nil { + t.Fatal("expected an error when --access-token / --expires-at are missing") + } +} + +// No token anywhere to import is a clear error, not a hang or a panic. +func TestAuthImport_NoTokenFails(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Cleanup(resetImportFlags) + + importCmd.SetIn(strings.NewReader("\n")) + t.Cleanup(func() { importCmd.SetIn(nil) }) + + if err := importCmd.RunE(importCmd, nil); err == nil { + t.Fatal("expected an error when no refresh token is supplied") + } +} + +func resetImportFlags() { + importRefreshTokenFlag = "" + importAccessTokenFlag = "" + importExpiresAtFlag = "" + importNoVerifyFlag = false +} + // Without the env var and without a saved token, status still fails and // points at both recovery paths. func TestAuthStatus_NoTokenFails(t *testing.T) { diff --git a/cmd/prime.go b/cmd/prime.go index 3577762..38bb6d0 100644 --- a/cmd/prime.go +++ b/cmd/prime.go @@ -49,7 +49,8 @@ GOTCHAS if data calls fail with "server is deprecated". - Headless: set LIFTOFF_REFRESH_TOKEN to skip 'auth login' entirely (no token file is read or written). Required for Google Sign-In accounts, - which have no password to type. + which have no password to type. To persist such a token to the normal + token file instead, run 'auth import --refresh-token ...' once. - Bodyweight is read off Post.bodyweight (the value you entered for that workout). No workout that day means no bodyweight that day. - 'workouts stats' bins exercises by name. Renaming an exercise in