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
Binary file added .DS_Store
Binary file not shown.
13 changes: 12 additions & 1 deletion cmd/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
41 changes: 41 additions & 0 deletions cmd/auth_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
3 changes: 3 additions & 0 deletions cmd/prime.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
44 changes: 39 additions & 5 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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":"<token>"}}
input, _ := json.Marshal(map[string]any{
"0": map[string]any{"json": refreshToken},
Expand Down Expand Up @@ -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.
Expand Down
Loading