Sign in to the hub with its Better Auth device flow; session token as bearer
CI / build (push) Successful in 28s
CI / build (push) Successful in 28s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
7887eada61
commit
bfd25e2b84
2 files changed
+62
-80
No files matched your search
@@ -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
|
||||||
|
|
||||||
|
|||||||
+56
-76
@@ -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:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user