Skip to content
Open
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
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
81 changes: 81 additions & 0 deletions cmd/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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)")
}
65 changes: 65 additions & 0 deletions cmd/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Expand Down
3 changes: 2 additions & 1 deletion cmd/prime.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading