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
+63 -81

No files matched your search

+6 -4
View File
@@ -56,10 +56,12 @@ leaves the machine.
## The hub ## 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 `t5de-proxy signin [-hub URL]` signs the proxy in to T5DE's hub (git.t5de.dev/t5de/t5de-hub; URL is the hub's
enter on the hub's website (`/device`) and saves the sign-in in `%APPDATA%\T5DE\proxy\hub.json`, the refresh API): it shows a code to enter on the hub's website (`/device`, OAuth 2.0 device authorization) and saves the
token encrypted for the Windows user (DPAPI). From then on the running proxy keeps a WebSocket to the hub session token it gets in `%APPDATA%\T5DE\proxy\hub.json`, encrypted for the Windows user (DPAPI). From then on the
(`internal/hub`), reporting which IMVU account the client signed in as and the community products it wears. 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 ## Building
+57 -77
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 // 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 // enters on the hub's website (OAuth 2.0 device authorization, by the hub's Better Auth), and a live connection
// community products it wears. // 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 package hub
import ( import (
@@ -11,7 +14,6 @@ import (
"io" "io"
"log" "log"
"net/http" "net/http"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -27,11 +29,11 @@ type Item struct {
Name string `json:"name"` 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 { type Saved struct {
URL string `json:"url"` URL string `json:"url"`
RefreshToken string `json:"-"` Token string `json:"-"`
Protected []byte `json:"refresh_token"` Protected []byte `json:"token"`
} }
// ErrSignedOut means there's no sign-in, or the hub ended it. // 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 { if err != nil {
return nil, fmt.Errorf("reading the hub sign-in: %w", err) return nil, fmt.Errorf("reading the hub sign-in: %w", err)
} }
s.RefreshToken = string(tok) s.Token = string(tok)
return &s, nil return &s, nil
} }
func (s *Saved) Save(path string) error { func (s *Saved) Save(path string) error {
p, err := protect([]byte(s.RefreshToken)) p, err := protect([]byte(s.Token))
if err != nil { if err != nil {
return err return err
} }
@@ -77,42 +79,36 @@ func (s *Saved) Save(path string) error {
return os.Rename(tmp, path) return os.Rename(tmp, path)
} }
type tokens struct { // ClientID is the proxy's client ID in device sign-in.
AccessToken string `json:"access_token"` const ClientID = "t5de-proxy"
RefreshToken string `json:"refresh_token"`
Error string `json:"error"`
}
func post(ctx context.Context, endpoint string, form url.Values) (*tokens, int, error) { // UserAgent marks the proxy's requests; the hub counts "computers signed in" by it.
req, err := http.NewRequestWithContext(ctx, "POST", endpoint, strings.NewReader(form.Encode())) 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 { if err != nil {
return nil, 0, err return 0, err
} }
req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "t5de-proxy") req.Header.Set("User-Agent", UserAgent)
resp, err := http.DefaultClient.Do(req) resp, err := http.DefaultClient.Do(req)
if err != nil { if err != nil {
return nil, 0, err return 0, err
} }
defer resp.Body.Close() defer resp.Body.Close()
var t tokens data, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if err := json.Unmarshal(data, out); err != nil {
if err := json.Unmarshal(body, &t); err != nil { return resp.StatusCode, fmt.Errorf("the hub answered %d", resp.StatusCode)
return nil, 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 // 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. // on), and waits until the person has entered it.
func SignIn(ctx context.Context, hubURL string, show func(code, page string)) (*Saved, error) { func SignIn(ctx context.Context, hubURL string, show func(code, page string)) (*Saved, error) {
hubURL = strings.TrimSuffix(hubURL, "/") 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 { var device struct {
DeviceCode string `json:"device_code"` DeviceCode string `json:"device_code"`
UserCode string `json:"user_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"` Interval int `json:"interval"`
ExpiresIn int `json:"expires_in"` ExpiresIn int `json:"expires_in"`
} }
err = json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&device) status, err := postJSON(ctx, hubURL+"/api/auth/device/code", map[string]string{"client_id": ClientID}, &device)
resp.Body.Close() if err != nil {
if err != nil || device.DeviceCode == "" { return nil, err
return nil, fmt.Errorf("the hub didn't give a sign-in code (%d)", resp.StatusCode) }
if device.DeviceCode == "" {
return nil, fmt.Errorf("the hub didn't give a sign-in code (%d)", status)
} }
show(device.UserCode, device.Complete) show(device.UserCode, device.Complete)
interval := time.Duration(max(device.Interval, 2)) * time.Second 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() return nil, ctx.Err()
case <-time.After(interval): 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 { if err != nil {
continue continue
} }
switch t.Error { switch {
case "": case t.AccessToken != "":
return &Saved{URL: hubURL, RefreshToken: t.RefreshToken}, nil return &Saved{URL: hubURL, Token: t.AccessToken}, nil
case "authorization_pending", "slow_down": case t.Error == "authorization_pending":
continue case t.Error == "slow_down":
interval += 5 * time.Second
default: default:
return nil, fmt.Errorf("sign-in failed: %s", t.Error) 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 { 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" 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{ conn, resp, err := websocket.Dial(ctx, wsURL, &websocket.DialOptions{HTTPHeader: http.Header{
"Authorization": {"Bearer " + access}, "User-Agent": {"t5de-proxy"}}}) "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 { if err != nil {
return err return err
} }
@@ -270,6 +251,10 @@ func (c *Client) connect(ctx context.Context) error {
for { for {
_, data, err := conn.Read(ctx) _, data, err := conn.Read(ctx)
if err != nil { 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 errs <- err
return return
} }
@@ -285,8 +270,6 @@ func (c *Client) connect(ctx context.Context) error {
defer cancel() defer cancel()
return conn.Write(wctx, websocket.MessageText, b) 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), "" sentID, sentOutfit := int64(-1), ""
for { for {
c.mu.Lock() c.mu.Lock()
@@ -310,9 +293,6 @@ func (c *Client) connect(ctx context.Context) error {
return ctx.Err() return ctx.Err()
case err := <-errs: case err := <-errs:
return err return err
case <-renew:
conn.Close(websocket.StatusNormalClosure, "renewing")
return errors.New("renewing the access token")
case <-c.wake: case <-c.wake:
} }
} }