From bfd25e2b8426669885d26f6e7f62f74bc9024cdb Mon Sep 17 00:00:00 2001 From: dhkatz Date: Thu, 29 Oct 2026 04:40:35 -0700 Subject: [PATCH] Sign in to the hub with its Better Auth device flow; session token as bearer Co-Authored-By: Claude Opus 5.5 --- README.md | 10 ++-- internal/hub/hub.go | 134 +++++++++++++++++++------------------------- 2 files changed, 63 insertions(+), 81 deletions(-) diff --git a/README.md b/README.md index 1f2e60b..56274cb 100644 --- a/README.md +++ b/README.md @@ -56,10 +56,12 @@ leaves the machine. ## The hub -`t5de-proxy signin [-hub URL]` signs the proxy in to T5DE's hub (git.t5de.dev/t5de/t5de-hub): it shows a code to -enter on the hub's website (`/device`) and saves the sign-in in `%APPDATA%\T5DE\proxy\hub.json`, the refresh -token encrypted for the Windows user (DPAPI). From then on the running proxy keeps a WebSocket to the hub -(`internal/hub`), reporting which IMVU account the client signed in as and the community products it wears. +`t5de-proxy signin [-hub URL]` signs the proxy in to T5DE's hub (git.t5de.dev/t5de/t5de-hub; URL is the hub's +API): it shows a code to enter on the hub's website (`/device`, OAuth 2.0 device authorization) and saves the +session token it gets in `%APPDATA%\T5DE\proxy\hub.json`, encrypted for the Windows user (DPAPI). From then on the +running proxy keeps a WebSocket to the hub (`internal/hub`) with the token as a bearer token, reporting which IMVU +account the client signed in as and the community products it wears. If the hub ends the session (signed out +everywhere, banned), the proxy stops connecting until `signin` is run again. ## Building diff --git a/internal/hub/hub.go b/internal/hub/hub.go index 87185a3..339115f 100644 --- a/internal/hub/hub.go +++ b/internal/hub/hub.go @@ -1,6 +1,9 @@ // Package hub connects the proxy to T5DE's hub (git.t5de.dev/t5de/t5de-hub): signing in with a code the person -// enters on the hub's website, and a live connection reporting which IMVU account the client is using and the -// community products it wears. +// enters on the hub's website (OAuth 2.0 device authorization, by the hub's Better Auth), and a live connection +// reporting which IMVU account the client is using and the community products it wears. +// +// Signing in gives a session token, kept encrypted for the Windows user and sent as a bearer token. The hub extends +// the session while it's used, and ends it when the person signs their computers out or is banned. package hub import ( @@ -11,7 +14,6 @@ import ( "io" "log" "net/http" - "net/url" "os" "path/filepath" "strings" @@ -27,11 +29,11 @@ type Item struct { Name string `json:"name"` } -// Saved is the proxy's sign-in, in /hub.json. The refresh token is encrypted for the Windows user. +// Saved is the proxy's sign-in, in /hub.json. The session token is encrypted for the Windows user. type Saved struct { - URL string `json:"url"` - RefreshToken string `json:"-"` - Protected []byte `json:"refresh_token"` + URL string `json:"url"` + Token string `json:"-"` + Protected []byte `json:"token"` } // ErrSignedOut means there's no sign-in, or the hub ended it. @@ -53,12 +55,12 @@ func Load(path string) (*Saved, error) { if err != nil { return nil, fmt.Errorf("reading the hub sign-in: %w", err) } - s.RefreshToken = string(tok) + s.Token = string(tok) return &s, nil } func (s *Saved) Save(path string) error { - p, err := protect([]byte(s.RefreshToken)) + p, err := protect([]byte(s.Token)) if err != nil { return err } @@ -77,42 +79,36 @@ func (s *Saved) Save(path string) error { return os.Rename(tmp, path) } -type tokens struct { - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - Error string `json:"error"` -} +// ClientID is the proxy's client ID in device sign-in. +const ClientID = "t5de-proxy" -func post(ctx context.Context, endpoint string, form url.Values) (*tokens, int, error) { - req, err := http.NewRequestWithContext(ctx, "POST", endpoint, strings.NewReader(form.Encode())) +// UserAgent marks the proxy's requests; the hub counts "computers signed in" by it. +var UserAgent = "t5de-proxy" + +func postJSON(ctx context.Context, endpoint string, body any, out any) (int, error) { + b, _ := json.Marshal(body) + req, err := http.NewRequestWithContext(ctx, "POST", endpoint, strings.NewReader(string(b))) if err != nil { - return nil, 0, err + return 0, err } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("User-Agent", "t5de-proxy") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", UserAgent) resp, err := http.DefaultClient.Do(req) if err != nil { - return nil, 0, err + return 0, err } defer resp.Body.Close() - var t tokens - body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) - if err := json.Unmarshal(body, &t); err != nil { - return nil, resp.StatusCode, fmt.Errorf("the hub answered %d", resp.StatusCode) + data, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err := json.Unmarshal(data, out); err != nil { + return resp.StatusCode, fmt.Errorf("the hub answered %d", resp.StatusCode) } - return &t, resp.StatusCode, nil + return resp.StatusCode, nil } // SignIn signs the proxy in: it gets a code from the hub, shows it (show gets the code and the page to enter it // on), and waits until the person has entered it. func SignIn(ctx context.Context, hubURL string, show func(code, page string)) (*Saved, error) { hubURL = strings.TrimSuffix(hubURL, "/") - req, _ := http.NewRequestWithContext(ctx, "POST", hubURL+"/api/v1/device/code", nil) - req.Header.Set("User-Agent", "t5de-proxy") - resp, err := http.DefaultClient.Do(req) - if err != nil { - return nil, err - } var device struct { DeviceCode string `json:"device_code"` UserCode string `json:"user_code"` @@ -120,10 +116,12 @@ func SignIn(ctx context.Context, hubURL string, show func(code, page string)) (* Interval int `json:"interval"` ExpiresIn int `json:"expires_in"` } - err = json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&device) - resp.Body.Close() - if err != nil || device.DeviceCode == "" { - return nil, fmt.Errorf("the hub didn't give a sign-in code (%d)", resp.StatusCode) + status, err := postJSON(ctx, hubURL+"/api/auth/device/code", map[string]string{"client_id": ClientID}, &device) + if err != nil { + return nil, err + } + if device.DeviceCode == "" { + return nil, fmt.Errorf("the hub didn't give a sign-in code (%d)", status) } show(device.UserCode, device.Complete) interval := time.Duration(max(device.Interval, 2)) * time.Second @@ -134,15 +132,24 @@ func SignIn(ctx context.Context, hubURL string, show func(code, page string)) (* return nil, ctx.Err() case <-time.After(interval): } - t, _, err := post(ctx, hubURL+"/api/v1/device/token", url.Values{"device_code": {device.DeviceCode}}) + var t struct { + AccessToken string `json:"access_token"` + Error string `json:"error"` + } + _, err := postJSON(ctx, hubURL+"/api/auth/device/token", map[string]string{ + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + "device_code": device.DeviceCode, + "client_id": ClientID, + }, &t) if err != nil { continue } - switch t.Error { - case "": - return &Saved{URL: hubURL, RefreshToken: t.RefreshToken}, nil - case "authorization_pending", "slow_down": - continue + switch { + case t.AccessToken != "": + return &Saved{URL: hubURL, Token: t.AccessToken}, nil + case t.Error == "authorization_pending": + case t.Error == "slow_down": + interval += 5 * time.Second default: return nil, fmt.Errorf("sign-in failed: %s", t.Error) } @@ -226,39 +233,13 @@ func (c *Client) Run(ctx context.Context) { } } -// accessToken trades the refresh token for an access token (and a new refresh token, saved). -func (c *Client) accessToken(ctx context.Context) (string, error) { - c.mu.Lock() - saved := *c.saved - c.mu.Unlock() - t, status, err := post(ctx, saved.URL+"/api/v1/token/refresh", url.Values{"refresh_token": {saved.RefreshToken}}) - if err != nil { - return "", err - } - if status == http.StatusUnauthorized || status == http.StatusForbidden { - return "", fmt.Errorf("%w: %s", ErrSignedOut, t.Error) - } - if t.Error != "" || t.AccessToken == "" { - return "", fmt.Errorf("the hub answered %d %s", status, t.Error) - } - saved.RefreshToken = t.RefreshToken - if err := saved.Save(c.path); err != nil { - return "", err - } - c.mu.Lock() - c.saved = &saved - c.mu.Unlock() - return t.AccessToken, nil -} - func (c *Client) connect(ctx context.Context) error { - access, err := c.accessToken(ctx) - if err != nil { - return err - } wsURL := strings.Replace(strings.Replace(c.saved.URL, "https://", "wss://", 1), "http://", "ws://", 1) + "/api/v1/realtime" - conn, _, err := websocket.Dial(ctx, wsURL, &websocket.DialOptions{HTTPHeader: http.Header{ - "Authorization": {"Bearer " + access}, "User-Agent": {"t5de-proxy"}}}) + conn, resp, err := websocket.Dial(ctx, wsURL, &websocket.DialOptions{HTTPHeader: http.Header{ + "Authorization": {"Bearer " + c.saved.Token}, "User-Agent": {UserAgent}}}) + if resp != nil && (resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden) { + return fmt.Errorf("%w: the hub answered %d", ErrSignedOut, resp.StatusCode) + } if err != nil { return err } @@ -270,6 +251,10 @@ func (c *Client) connect(ctx context.Context) error { for { _, data, err := conn.Read(ctx) if err != nil { + // The hub closes with 4001 when the person signs their computers out or is banned. + if websocket.CloseStatus(err) == 4001 { + err = fmt.Errorf("%w: %v", ErrSignedOut, err) + } errs <- err return } @@ -285,8 +270,6 @@ func (c *Client) connect(ctx context.Context) error { defer cancel() return conn.Write(wctx, websocket.MessageText, b) } - // Access tokens are short-lived; reconnecting with a fresh one before it expires keeps the hub's check current. - renew := time.After(12 * time.Minute) sentID, sentOutfit := int64(-1), "" for { c.mu.Lock() @@ -310,9 +293,6 @@ func (c *Client) connect(ctx context.Context) error { return ctx.Err() case err := <-errs: return err - case <-renew: - conn.Close(websocket.StatusNormalClosure, "renewing") - return errors.New("renewing the access token") case <-c.wake: } }