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.