Skip to content

fix(client): use form-urlencoded for OAuth token endpoint#24

Merged
omarshahine merged 5 commits intosteipete:mainfrom
omarshahine:fix/oauth-form-encoding
Apr 16, 2026
Merged

fix(client): use form-urlencoded for OAuth token endpoint#24
omarshahine merged 5 commits intosteipete:mainfrom
omarshahine:fix/oauth-form-encoding

Conversation

@omarshahine
Copy link
Copy Markdown
Collaborator

@omarshahine omarshahine commented Mar 15, 2026

Problem

Three bugs in the API client that make eightctl unreliable or non-functional:

1. Wrong OAuth content type and credentials in token endpoint

authTokenEndpoint() sends application/json but the Eight Sleep auth server (auth-api.8slp.net/v1/tokens) expects standard OAuth2 application/x-www-form-urlencoded. It also hardcodes client_id: "sleep-client" with an empty client_secret instead of using the actual app credentials.

The JSON request always returns 400 (Joi validation), causing every authentication to fall through to authLegacyLogin(). After a few attempts the rate limiter kicks in.

Note: PR #15 correctly identified the credential issue but kept JSON encoding, which is why the token endpoint still fails even with that fix applied.

2. Gzip responses not decompressed

do() sets Accept-Encoding: gzip explicitly, which disables Go's automatic transparent decompression. The response body arrives as raw gzip bytes, causing:

Error: invalid character '\x1f' looking for beginning of value

3. Infinite retry loops on 429 and 401

Both 429 (rate limited) and 401 (unauthorized) responses trigger unbounded recursive retries with no backoff, leading to permanent loops when auth is broken.

Fix

OAuth auth:

  • Use application/x-www-form-urlencoded content type (standard OAuth2 token request format)
  • Use c.ClientID and c.ClientSecret instead of hardcoded "sleep-client" / ""
  • Make authURL a package-level var (was const) so tests can redirect it to a local server

Drop legacy /login fallback:

  • The legacy /login endpoint no longer works reliably upstream
  • The silent fallback was masking real OAuth errors — with form-encoding fixed, OAuth works correctly
  • Authenticate() now calls the token endpoint directly

Gzip decompression:

Bounded retries:

Tests added:

  • Token endpoint sends form-urlencoded with correct client credentials
  • Auth returns error on failure (no silent fallback)
  • No explicit gzip header sent (Go Transport handles it)
  • Bounded 429 retry with backoff
  • Retry cap prevents infinite loops

Tested against a live Pod 2 Pro.

Fixes #5, fixes #7, fixes #8, fixes #12, fixes #14, fixes #29


Note: Issue #10 (keyring hang in headless environments) is not addressed in this PR. A separate PR with the headless keyring fallback (credit to @davidfencik) can follow.

Lobster and others added 2 commits March 14, 2026 23:59
The Eight Sleep auth server (auth-api.8slp.net/v1/tokens) expects
standard OAuth2 form-urlencoded requests, not JSON. The previous
implementation sent JSON with hardcoded "sleep-client" credentials,
which caused a 400 from Joi validation. The fallback to legacy
/login then tripped the rate limiter, resulting in a permanent 429
loop.

Changes:
- Send application/x-www-form-urlencoded instead of application/json
- Use c.ClientID and c.ClientSecret (the real app creds extracted
  from the Android APK) instead of hardcoded "sleep-client"/""
- Make authURL a var so tests can point it at a local server
- Add tests for form encoding, credential passthrough, and
  legacy login fallback

Fixes steipete#7, fixes steipete#8, fixes steipete#12

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The do() method sends Accept-Encoding: gzip but never decompresses
the response body, causing json.Decode to fail with:

    invalid character '\x1f' looking for beginning of value

(0x1f is the gzip magic byte.)

Check Content-Encoding: gzip on responses and wrap the body in a
gzip.Reader before decoding. Added test with a mock gzip response.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@omarshahine
Copy link
Copy Markdown
Collaborator Author

Hey @steipete — friendly ping on this and #26. This one fixes the OAuth token endpoint (form-urlencoded per spec) and gzip decompression, and #26 adds away/vacation mode. Happy to adjust anything if needed!

…dless envs

- Replace infinite 429/401 retry loops with bounded retries (max 3) and
  exponential backoff to prevent permanent rate-limit storms
- Remove explicit Accept-Encoding: gzip header; let Go's http.Transport
  handle compression transparently (simpler, no manual gzip.NewReader)
- Detect headless environments (SSH, no TTY, EIGHTCTL_KEYRING_FILE=1) and
  fall back to file-based keyring to avoid macOS Keychain hang

Credit to @davidfencik (steipete#16) for the retry and keyring patterns, and
@petersentaylor (steipete#27) for the simpler gzip approach.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The legacy /login endpoint no longer works reliably upstream, and the
silent fallback was masking real OAuth errors. Remove it so OAuth
failures surface directly. Also revert the keyring-backend tweaks from
9f49cf0 — leave tokencache behavior identical to main.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@omarshahine omarshahine force-pushed the fix/oauth-form-encoding branch 2 times, most recently from 6ce25bf to 2bda5c8 Compare April 15, 2026 15:40
`disable-all: true` and `disable: [errcheck, unused]` can't be combined
— golangci-lint errors with "can't combine options --disable-all and
--disable". Remove the redundant `disable` list and stale `revive`
settings since only `govet` is enabled.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@omarshahine omarshahine force-pushed the fix/oauth-form-encoding branch from 2bda5c8 to b573230 Compare April 15, 2026 15:43
@omarshahine
Copy link
Copy Markdown
Collaborator Author

Code Review

+164 / -79 lines across 4 files, 5 commits

Overview

Fixes the three interconnected bugs that made eightctl non-functional: wrong OAuth encoding, explicit gzip header disabling Go's transparent decompression, and unbounded retry loops. Also removes the dead legacy /login fallback that was masking the real auth problem, and fixes CI (golangci-lint v2 + config cleanup).

Correctness

  • OAuth form-encoding: Correct. RFC 6749 specifies application/x-www-form-urlencoded for token requests. The old JSON body was failing server-side Joi validation.
  • Gzip: Correct. Removing the explicit Accept-Encoding: gzip header lets http.Transport add it and auto-decompress. Zero new code needed.
  • Bounded retries: Correct. doRetry caps at maxRetries (3) with linear backoff (2s, 4s, 6s). Both 429 and 401 paths check the cap before recursing.
  • Legacy login removal: Correct. No remaining references to authLegacyLogin, /login, or the legacy code path anywhere in the codebase. The silent fallback was masking real OAuth errors.

Test Coverage

Area Test Status
Form-encoded auth TestAuthTokenEndpoint_FormEncoded Verifies content-type, credentials, grant_type
Auth failure TestAuthTokenEndpoint_ReturnsErrorOnFailure No silent fallback
Gzip transparency TestNoExplicitGzipHeader Confirms Go Transport handles it
429 retry + backoff Test429Retry Verifies 2s+ backoff on first retry
429 retry cap Test429RetryCapped Confirms error after maxRetries

Notes

Reviewed and tested locally. CI passing.

@omarshahine
Copy link
Copy Markdown
Collaborator Author

Closes #5, #7, #8, #12, #14, #29

@omarshahine omarshahine merged commit b9ebc22 into steipete:main Apr 16, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment