Sign in to the hub with its Better Auth device flow; session token as bearer
CI / build (push) Successful in 28s

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
dhkatzandClaude Opus 5.5 committed 2026-10-29 04:40:35 -07:00
1 parent 7887eada61
commit bfd25e2b84
2 files changed
+62 -80

No files matched your search

+6 -4
View File
@@ -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
+56 -76
View File
@@ -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 <data dir>/hub.json. The refresh token is encrypted for the Windows user.
// Saved is the proxy's sign-in, in <data dir>/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"`
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:
}
}