From 684ef8fd6e881c7884b0aba0b6d8157b4ad21341 Mon Sep 17 00:00:00 2001 From: DTTerastar Date: Mon, 20 Jul 2026 22:36:54 -0400 Subject: [PATCH] feat(auth): LIFTOFF_REFRESH_TOKEN headless auth path (#55) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Google Sign-In accounts have no password, so 'auth login' — which POSTs email+password to user.signIn — has no credential pair to send and the backend rejects password reset for them outright. Those accounts had no working path to any authenticated subcommand. Also closes a standing gap against quantcli/common CONTRACT §5, which requires every CLI to accept {SERVICE}_* env vars so a fresh container can export without an interactive login. liftoff-export had no headless path at all. Set LIFTOFF_REFRESH_TOKEN and the saved token file is bypassed entirely, neither read nor written — writing would clobber the tokens of whoever is logged in on the same machine. Costs one refresh call per invocation, which is the right trade for a CLI that runs once per export. Splits the unexported refresh() out of Refresh() so the headless path can exchange tokens without persisting. Refresh() keeps its save-on-success behavior for the interactive path. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_013JJnPtEtP97to9Rv2QMoer --- .DS_Store | Bin 0 -> 6148 bytes cmd/auth.go | 13 ++++++++++++- cmd/auth_test.go | 41 +++++++++++++++++++++++++++++++++++++++ cmd/prime.go | 3 +++ internal/auth/auth.go | 44 +++++++++++++++++++++++++++++++++++++----- 5 files changed, 95 insertions(+), 6 deletions(-) create mode 100644 .DS_Store create mode 100644 cmd/auth_test.go diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..755aa16e8f47923e92fe4a3bd8526ca6fdd87842 GIT binary patch literal 6148 zcmeHKJxc>Y5S`V4K@ltj1)BpKE78VII5mO@g5n=A@gxxv6C{FGUL|O2pTgSO!phia zVQ+f>fUTWxX4kmOC1Rt9%)sv3n~$Bj7j7p@L}vLYnI{?}qC5s;ZWvP!VLOkE3^>g= z(8wM~l+a$aJionL&v>gs8BhlP8Uy_84$&;_Qk9D2eZRGaD|IXDQL7gUrE0wdOaAq; zb>Dh?SnB1Ke8a0z;nU(H8=pgzLp^2Mr=~qgoNaAm)VKLHth~J(-*lvgZfvdonZ1`i z$eX0tT6mDWQ~?vEAUn-WWIg;E+9xla>8CCa-adXM{k?*EXp`$1#II7LB&_3X@zdhx zzqmR*Z$C?Z7w6~ATEl#IoG%Z~52#6dbO zYmVzu`56Y#vsv;Z7IjtzlmTU+%K)Db5e8%IFtsR02PU}!0CP}_U@biy7_kBvJ4`J? z12HxfXhV&=Vi+3^edPRNhp9yyPDV0gAD7v<8;X(a&`07#doW<6awK>|n64Uusc8Fu8Ut3vR`e7$UGmya2`y TQ;X0*^pAkhpp!E2s|>sYuF0+D literal 0 HcmV?d00001 diff --git a/cmd/auth.go b/cmd/auth.go index a62f9a3..b004b4d 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -78,12 +78,23 @@ This is a local check — no network call and no refresh is attempted, even when the saved token is expired. Use 'auth refresh' (or any export subcommand) to actually refresh. +If LIFTOFF_REFRESH_TOKEN is set it wins over any saved token, and status +reports that instead. Its validity is unknown without a network call, so +exit 0 there means "a token was supplied", not "the token works". + Per the quantcli shared contract: https://github.com/quantcli/common/blob/main/CONTRACT.md#5-auth`, RunE: func(cmd *cobra.Command, args []string) error { + // Headless mode has no saved token to inspect and no expiry to + // report until a refresh happens, so report the source and stop. + if auth.EnvRefreshToken() != "" { + fmt.Fprintln(cmd.OutOrStdout(), "using LIFTOFF_REFRESH_TOKEN (headless; no saved token consulted)") + return nil + } + store, err := auth.Load() if err != nil { - return fmt.Errorf("not logged in — run: liftoff-export auth login") + return fmt.Errorf("not logged in — run: liftoff-export auth login (or set LIFTOFF_REFRESH_TOKEN)") } exp := store.ExpiresAt.Local().Format(time.RFC3339) if time.Now().After(store.ExpiresAt) { diff --git a/cmd/auth_test.go b/cmd/auth_test.go new file mode 100644 index 0000000..b7b3fb3 --- /dev/null +++ b/cmd/auth_test.go @@ -0,0 +1,41 @@ +package cmd + +import ( + "bytes" + "strings" + "testing" +) + +// With LIFTOFF_REFRESH_TOKEN set, 'auth status' reports the headless source +// and exits 0 without consulting the saved token file — that file may not +// exist at all in a container. (#55) +func TestAuthStatus_HeadlessEnvWins(t *testing.T) { + t.Setenv("LIFTOFF_REFRESH_TOKEN", "rt-from-env") + t.Setenv("HOME", t.TempDir()) // no auth.json anywhere + + var out bytes.Buffer + statusCmd.SetOut(&out) + t.Cleanup(func() { statusCmd.SetOut(nil) }) + + if err := statusCmd.RunE(statusCmd, nil); err != nil { + t.Fatalf("headless status should succeed with no saved token, got: %v", err) + } + if !strings.Contains(out.String(), "LIFTOFF_REFRESH_TOKEN") { + t.Errorf("status should name the token source, got: %q", out.String()) + } +} + +// Without the env var and without a saved token, status still fails and +// points at both recovery paths. +func TestAuthStatus_NoTokenFails(t *testing.T) { + t.Setenv("LIFTOFF_REFRESH_TOKEN", "") + t.Setenv("HOME", t.TempDir()) + + err := statusCmd.RunE(statusCmd, nil) + if err == nil { + t.Fatal("expected an error when no token is available") + } + if !strings.Contains(err.Error(), "LIFTOFF_REFRESH_TOKEN") { + t.Errorf("error should mention the headless option, got: %v", err) + } +} diff --git a/cmd/prime.go b/cmd/prime.go index 8bb1abb..3577762 100644 --- a/cmd/prime.go +++ b/cmd/prime.go @@ -47,6 +47,9 @@ GOTCHAS - Workout dates are LOCAL — 11pm workouts bucket on the day you logged them. - API hosts rotate; set LIFTOFF_API_BASE=https://vX-Y-Z.api.getgymbros.com 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. - 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 diff --git a/internal/auth/auth.go b/internal/auth/auth.go index c28fa37..6fe0bcd 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -22,6 +22,18 @@ const ( const apiBaseEnvVar = "LIFTOFF_API_BASE" +// refreshTokenEnvVar is the headless auth path required by the quantcli +// contract (§5): a fresh container exports without an interactive login. +// It also covers accounts that have no password to type — Google Sign-In +// accounts can't use 'auth login' at all. (#55) +const refreshTokenEnvVar = "LIFTOFF_REFRESH_TOKEN" + +// EnvRefreshToken returns the refresh token supplied via the environment, +// or "" when unset. When set it takes precedence over the saved token file. +func EnvRefreshToken() string { + return strings.TrimSpace(os.Getenv(refreshTokenEnvVar)) +} + var ( resolveOnce sync.Once resolved string @@ -69,9 +81,21 @@ func configPath() string { // GetToken returns a valid access token, refreshing if needed. func GetToken() (string, error) { + // Headless mode: the environment supplies the refresh token, so there is + // no login step and no token file. Deliberately not persisted — writing + // would clobber the token file of whoever is logged in on this machine. + // Costs one refresh call per invocation, which is fine for a CLI. + if rt := EnvRefreshToken(); rt != "" { + store, err := refresh(rt) + if err != nil { + return "", fmt.Errorf("%s refresh failed: %w", refreshTokenEnvVar, err) + } + return store.AccessToken, nil + } + store, err := load() if err != nil { - return "", fmt.Errorf("not logged in — run: liftoff-export auth login") + return "", fmt.Errorf("not logged in — run: liftoff-export auth login (or set %s)", refreshTokenEnvVar) } if time.Now().After(store.ExpiresAt) { store, err = Refresh(store.RefreshToken) @@ -82,8 +106,19 @@ func GetToken() (string, error) { return store.AccessToken, nil } -// Refresh exchanges a refresh token for a new access token via user.refreshToken. +// Refresh exchanges a refresh token for a new access token and saves the +// result. Use refresh directly when the tokens must not touch disk. func Refresh(refreshToken string) (*TokenStore, error) { + store, err := refresh(refreshToken) + if err != nil { + return nil, err + } + return store, Save(store) +} + +// refresh exchanges a refresh token for a new access token via +// user.refreshToken, without persisting anything. +func refresh(refreshToken string) (*TokenStore, error) { // tRPC batch GET: /api/trpc/user.refreshToken?batch=1&input={"0":{"json":""}} input, _ := json.Marshal(map[string]any{ "0": map[string]any{"json": refreshToken}, @@ -135,12 +170,11 @@ func Refresh(refreshToken string) (*TokenStore, error) { expiresAt, _ := time.Parse(time.RFC3339Nano, batch[0].Result.Data.JSON.AccessTokenExpiresAt) - store := &TokenStore{ + return &TokenStore{ AccessToken: batch[0].Result.Data.JSON.AccessToken, RefreshToken: refreshToken, ExpiresAt: expiresAt.Add(-5 * time.Minute), // refresh 5min early - } - return store, Save(store) + }, nil } // Logout removes the stored auth tokens.