Skip to content
Merged
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
14 changes: 7 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,22 +27,22 @@ jobs:
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: ${{ runner.os }}-go-

- name: Install tools
run: |
go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.60.1
go install mvdan.cc/gofumpt@v0.6.0
- name: Install gofumpt
run: go install mvdan.cc/gofumpt@v0.7.0

- name: Format check (gofumpt)
run: |
# list files that would change
CHANGED=$(gofumpt -l .)
if [ -n "$CHANGED" ]; then
echo "gofumpt would reformat:\n$CHANGED" >&2
echo "gofumpt would reformat:" >&2
echo "$CHANGED" >&2
exit 1
fi

- name: Lint
run: golangci-lint run ./...
uses: golangci/golangci-lint-action@v7
with:
version: v2.1

- name: Test
run: go test ./...
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,4 @@ go.work.sum
# Editor/IDE
# .idea/
# .vscode/
eightctl
20 changes: 4 additions & 16 deletions .golangci.yml
Original file line number Diff line number Diff line change
@@ -1,26 +1,14 @@
version: 2
version: "2"

run:
timeout: 3m
tests: true

linters:
disable-all: true
disable:
- errcheck
- unused
default: none
enable:
- govet
# revive and errcheck disabled to keep CLI glue concise
# - revive
# - errcheck
linters-settings:
revive:
rules:
- name: unused-parameter
disabled: true
- name: package-comments
disabled: true

issues:
exclude-use-default: false
max-issues-per-linter: 0
max-same-issues: 0
2 changes: 1 addition & 1 deletion docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Eight Sleep Pod power/control + data-export CLI, written in Go. Targets macOS/Li
- Default OAuth client creds extracted from Android APK 7.39.17:
- `client_id`: `0894c7f33bb94800a03f1f4df13a4f38`
- `client_secret`: `f0954a3ed5763ba3d06834c73731a32f15f168f47d4f164751275def86db0c76`
- Auth flow: password grant at `https://auth-api.8slp.net/v1/tokens`; fallback legacy `/login` session token.
- Auth flow: OAuth password grant at `https://auth-api.8slp.net/v1/tokens` (form-urlencoded).
- Throttling: 429s observed; client retries with small delay and re-auths on 401.

## Configuration & Auth
Expand Down
110 changes: 32 additions & 78 deletions internal/client/eightsleep.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,14 @@ import (

const (
defaultBaseURL = "https://client-api.8slp.net/v1"
authURL = "https://auth-api.8slp.net/v1/tokens"
// Extracted from the official Eight Sleep Android app v7.39.17 (public client creds)
defaultClientID = "0894c7f33bb94800a03f1f4df13a4f38"
defaultClientSecret = "f0954a3ed5763ba3d06834c73731a32f15f168f47d4f164751275def86db0c76"
)

// authURL is a var so tests can point it at a local server.
var authURL = "https://auth-api.8slp.net/v1/tokens"

// Client represents Eight Sleep API client.
type Client struct {
Email string
Expand Down Expand Up @@ -65,12 +67,9 @@ func New(email, password, userID, clientID, clientSecret string) *Client {
}
}

// Authenticate fetches bearer token. Tries OAuth token endpoint first; falls back to /login used by app.
// Authenticate fetches a bearer token via the OAuth password-grant endpoint.
func (c *Client) Authenticate(ctx context.Context) error {
if err := c.authTokenEndpoint(ctx); err == nil {
return nil
}
return c.authLegacyLogin(ctx)
return c.authTokenEndpoint(ctx)
}

// EnsureUserID populates UserID by calling /users/me if missing.
Expand Down Expand Up @@ -116,19 +115,18 @@ func (c *Client) EnsureDeviceID(ctx context.Context) (string, error) {
}

func (c *Client) authTokenEndpoint(ctx context.Context) error {
payload := map[string]string{
"grant_type": "password",
"username": c.Email,
"password": c.Password,
"client_id": "sleep-client",
"client_secret": "",
}
body, _ := json.Marshal(payload)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, authURL, bytes.NewReader(body))
form := url.Values{}
form.Set("grant_type", "password")
form.Set("username", c.Email)
form.Set("password", c.Password)
form.Set("client_id", c.ClientID)
form.Set("client_secret", c.ClientSecret)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, authURL,
bytes.NewReader([]byte(form.Encode())))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")

resp, err := c.HTTP.Do(req)
if err != nil {
Expand Down Expand Up @@ -168,64 +166,6 @@ func (c *Client) authTokenEndpoint(ctx context.Context) error {
return nil
}

func (c *Client) authLegacyLogin(ctx context.Context) error {
payload := map[string]string{
"email": c.Email,
"password": c.Password,
}
body, _ := json.Marshal(payload)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/login", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json; charset=UTF-8")
req.Header.Set("Accept", "application/json")
req.Header.Set("Connection", "keep-alive")
req.Header.Set("User-Agent", "okhttp/4.9.3")
req.Header.Set("Accept-Encoding", "gzip")
resp, err := c.HTTP.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
b, _ := io.ReadAll(resp.Body)
log.Debug("legacy login failed", "status", resp.Status, "headers", resp.Header, "body", string(b))
return fmt.Errorf("login failed: %s", string(b))
}
var res struct {
Session struct {
UserID string `json:"userId"`
Token string `json:"token"`
ExpirationDate string `json:"expirationDate"`
} `json:"session"`
}
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
return err
}
if res.Session.Token == "" {
return errors.New("empty session token")
}
c.token = res.Session.Token
if res.Session.ExpirationDate != "" {
if t, err := time.Parse(time.RFC3339, res.Session.ExpirationDate); err == nil {
c.tokenExp = t
}
}
if c.tokenExp.IsZero() {
c.tokenExp = time.Now().Add(12 * time.Hour)
}
if c.UserID == "" {
c.UserID = res.Session.UserID
}
if err := tokencache.Save(c.Identity(), c.token, c.tokenExp, c.UserID); err != nil {
log.Debug("failed to cache token", "error", err)
} else {
log.Debug("saved token to cache (legacy)", "expires_at", c.tokenExp)
}
return nil
}

func (c *Client) ensureToken(ctx context.Context) error {
if c.token != "" && time.Now().Before(c.tokenExp) {
log.Debug("using in-memory token", "expires_in", time.Until(c.tokenExp).Round(time.Second))
Expand Down Expand Up @@ -256,7 +196,13 @@ func (c *Client) requireUser(ctx context.Context) error {
return c.EnsureUserID(ctx)
}

const maxRetries = 3

func (c *Client) do(ctx context.Context, method, path string, query url.Values, body any, out any) error {
return c.doRetry(ctx, method, path, query, body, out, 0)
}

func (c *Client) doRetry(ctx context.Context, method, path string, query url.Values, body any, out any, attempt int) error {
if err := c.ensureToken(ctx); err != nil {
return err
}
Expand All @@ -281,24 +227,32 @@ func (c *Client) do(ctx context.Context, method, path string, query url.Values,
req.Header.Set("Accept", "application/json")
req.Header.Set("Connection", "keep-alive")
req.Header.Set("User-Agent", "okhttp/4.9.3")
req.Header.Set("Accept-Encoding", "gzip")
// Note: we intentionally do NOT set Accept-Encoding. Go's http.Transport
// handles gzip transparently when the header is absent. Setting it
// explicitly disables automatic decompression.

resp, err := c.HTTP.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusTooManyRequests {
time.Sleep(2 * time.Second)
return c.do(ctx, method, path, query, body, out)
if attempt >= maxRetries {
return fmt.Errorf("rate limited after %d retries: %s %s", maxRetries, method, path)
}
time.Sleep(time.Duration(2*(attempt+1)) * time.Second)
return c.doRetry(ctx, method, path, query, body, out, attempt+1)
}
if resp.StatusCode == http.StatusUnauthorized {
if attempt >= maxRetries {
return fmt.Errorf("unauthorized after %d retries: %s %s", maxRetries, method, path)
}
c.token = ""
_ = tokencache.Clear(c.Identity())
if err := c.ensureToken(ctx); err != nil {
return err
}
return c.do(ctx, method, path, query, body, out)
return c.doRetry(ctx, method, path, query, body, out, attempt+1)
}
if resp.StatusCode >= 300 {
b, _ := io.ReadAll(resp.Body)
Expand Down
129 changes: 129 additions & 0 deletions internal/client/eightsleep_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package client

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
Expand Down Expand Up @@ -70,6 +71,107 @@ func TestRequireUserFilledAutomatically(t *testing.T) {
}
}

func TestAuthTokenEndpoint_FormEncoded(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Must be form-encoded, not JSON.
ct := r.Header.Get("Content-Type")
if ct != "application/x-www-form-urlencoded" {
t.Errorf("expected form-urlencoded, got %s", ct)
http.Error(w, "bad content type", http.StatusBadRequest)
return
}
if err := r.ParseForm(); err != nil {
t.Fatalf("ParseForm: %v", err)
}
// Verify correct client credentials are sent (not "sleep-client").
if got := r.PostFormValue("client_id"); got != defaultClientID {
t.Errorf("client_id = %q, want %q", got, defaultClientID)
}
if got := r.PostFormValue("client_secret"); got != defaultClientSecret {
t.Errorf("client_secret = %q, want %q", got, defaultClientSecret)
}
if got := r.PostFormValue("grant_type"); got != "password" {
t.Errorf("grant_type = %q, want password", got)
}
if got := r.PostFormValue("username"); got != "test@example.com" {
t.Errorf("username = %q, want test@example.com", got)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"access_token": "tok-123",
"expires_in": 3600,
"userId": "uid-abc",
})
}))
defer srv.Close()

old := authURL
authURL = srv.URL
defer func() { authURL = old }()

c := New("test@example.com", "secret", "", "", "")
c.HTTP = srv.Client()

if err := c.Authenticate(context.Background()); err != nil {
t.Fatalf("Authenticate: %v", err)
}
if c.token != "tok-123" {
t.Errorf("token = %q, want tok-123", c.token)
}
if c.UserID != "uid-abc" {
t.Errorf("UserID = %q, want uid-abc", c.UserID)
}
}

func TestAuthTokenEndpoint_ReturnsErrorOnFailure(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "nope", http.StatusBadRequest)
}))
defer srv.Close()

old := authURL
authURL = srv.URL
defer func() { authURL = old }()

c := New("test@example.com", "secret", "", "", "")
c.HTTP = srv.Client()

if err := c.Authenticate(context.Background()); err == nil {
t.Fatal("Authenticate: expected error, got nil")
}
}

func TestNoExplicitGzipHeader(t *testing.T) {
// Verify we don't send Accept-Encoding: gzip manually, so Go's
// http.Transport handles decompression transparently.
mux := http.NewServeMux()
mux.HandleFunc("/check", func(w http.ResponseWriter, r *http.Request) {
ae := r.Header.Get("Accept-Encoding")
// Go's Transport adds its own Accept-Encoding when we don't set one.
// The key assertion: our code must NOT set it to exactly "gzip".
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"accept_encoding": ae})
})
srv := httptest.NewServer(mux)
defer srv.Close()

c := New("e", "p", "uid", "", "")
c.BaseURL = srv.URL
c.token = "t"
c.tokenExp = time.Now().Add(time.Hour)
c.HTTP = srv.Client()

var out map[string]string
if err := c.do(context.Background(), http.MethodGet, "/check", nil, nil, &out); err != nil {
t.Fatalf("do: %v", err)
}
// Go's transport sets "gzip" automatically but handles decompression.
// We just verify our code didn't break anything.
if out["accept_encoding"] == "" {
t.Fatal("expected Accept-Encoding to be set by Go's transport")
}
}

func Test429Retry(t *testing.T) {
count := 0
mux := http.NewServeMux()
Expand Down Expand Up @@ -101,3 +203,30 @@ func Test429Retry(t *testing.T) {
t.Fatalf("expected backoff, got %v", elapsed)
}
}

func Test429RetryCapped(t *testing.T) {
// Verify retries are bounded: after maxRetries, return an error.
count := 0
mux := http.NewServeMux()
mux.HandleFunc("/always429", func(w http.ResponseWriter, r *http.Request) {
count++
w.WriteHeader(http.StatusTooManyRequests)
})
srv := httptest.NewServer(mux)
defer srv.Close()

c := New("email", "pass", "uid", "", "")
c.BaseURL = srv.URL
c.token = "t"
c.tokenExp = time.Now().Add(time.Hour)
c.HTTP = srv.Client()

err := c.do(context.Background(), http.MethodGet, "/always429", nil, nil, nil)
if err == nil {
t.Fatal("expected error after exhausting retries")
}
// 1 initial + maxRetries = 4 total attempts
if count != maxRetries+1 {
t.Fatalf("expected %d attempts, got %d", maxRetries+1, count)
}
}
Loading