diff options
-rw-r--r-- | pkg/stryder/stryder.go | 108 | ||||
-rw-r--r-- | pkg/stryder/stryder_test.go | 42 |
2 files changed, 150 insertions, 0 deletions
diff --git a/pkg/stryder/stryder.go b/pkg/stryder/stryder.go index 20d5b66..35bffbb 100644 --- a/pkg/stryder/stryder.go +++ b/pkg/stryder/stryder.go @@ -1,3 +1,111 @@ // Package stryder is a client for parts of the Stryder API used by Northstar // for authentication and license verification. package stryder + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" +) + +var ( + ErrStryder = errors.New("internal stryder error") + ErrInvalidToken = errors.New("invalid token") + ErrMultiplayerNotAllowed = errors.New("multiplayer not allowed") + ErrInvalidGame = errors.New("invalid game") +) + +// NucleusAuth verifies the provided scoped nucleus token and uid for Titanfall +// 2 multiplayer. +func NucleusAuth(ctx context.Context, token string, uid uint64) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://r2-pc.stryder.respawn.com/nucleus-oauth.php?qt=origin-requesttoken&type=server_token&code="+url.PathEscape(token)+"&forceTrial=0&proto=0&json=1&&env=production&userId="+strings.ToUpper(strconv.FormatUint(uid, 16)), nil) + if err != nil { + return nil, err + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + return nucleusAuth(resp) +} + +func nucleusAuth(r *http.Response) ([]byte, error) { + buf, err := io.ReadAll(r.Body) + if err != nil { + return nil, err + } + + // clean it up a bit + buf = bytes.TrimSpace(buf) + + // check if the response is empty + if len(buf) == 0 { + return buf, fmt.Errorf("%w: empty response", ErrStryder) + } + + // the subset of the response that we care about + var obj struct { + // error + Success *bool `json:"success,omitempty"` + Status json.Number `json:"status,omitempty"` + Error any `json:"error,omitempty"` + + // success + StoreURI string `json:"storeUri,omitempty"` + HasOnlineAccess json.Number `json:"hasOnlineAccess,omitempty"` + } + + // parse it as normal json + if err = json.Unmarshal(buf, &obj); err != nil { + // fix nested json objects inserted as-is + tmp := bytes.ReplaceAll(buf, []byte(`"{`), []byte(`{`)) + tmp = bytes.ReplaceAll(tmp, []byte(`}"`), []byte(`}`)) + + // parse the fixed json, but return the original error if it's also bad + if json.Unmarshal(tmp, &obj) != nil { + return buf, fmt.Errorf("%w: invalid json response %#q: %v", ErrStryder, string(buf), err) + } + } + + // check if it's a stryder error response + if obj.Success != nil && !*obj.Success { + // check if the error is an origin one (i.e., a nested json object) and if it's for an invalid/expired token + if castOr(castOr(obj.Error, map[string]any{})["error"], "") == "invalid_grant" { + return buf, ErrInvalidToken + } + + // some other error + oerr, _ := json.Marshal(obj.Error) + return buf, fmt.Errorf("%w: error response %#q (status %#v)", ErrStryder, oerr, obj.Status) + } + + // ensure the token is for the correct game + if !strings.Contains(obj.StoreURI, "/titanfall-2") { + return buf, ErrInvalidGame + } + + // ensure hasOnlineAccess is true + if obj.HasOnlineAccess != "1" { + return buf, ErrMultiplayerNotAllowed + } + + // otherwise it's fine + return buf, nil +} + +func castOr[T any](v any, d T) T { + if x, ok := v.(T); ok { + return x + } + return d +} diff --git a/pkg/stryder/stryder_test.go b/pkg/stryder/stryder_test.go new file mode 100644 index 0000000..23ce717 --- /dev/null +++ b/pkg/stryder/stryder_test.go @@ -0,0 +1,42 @@ +package stryder + +import ( + "bytes" + "errors" + "io" + "net/http" + "strings" + "testing" +) + +func TestNucleusAuth(t *testing.T) { + testNucleusAuth(t, "Success", `{"token":"...","hasOnlineAccess":"1","expiry":"14399","storeUri":"https://www.origin.com/store/titanfall/titanfall-2/standard-edition"}`, nil) + testNucleusAuth(t, "NoMultiplayer", `{"token":"...","hasOnlineAccess":"0","expiry":"14399","storeUri":"https://www.origin.com/store/titanfall/titanfall-2/standard-edition"}`, ErrMultiplayerNotAllowed) + testNucleusAuth(t, "InvalidToken", `{"success": false, "status": "400", "error": "{"error":"invalid_grant","error_description":"code is invalid","code":100100}"}`, ErrInvalidToken) + testNucleusAuth(t, "StryderBadRequest", `{"success": false, "status": "400", "error": "{"error":"invalid_request","error_description":"code is not issued to this environment","code":100119}"}`, ErrStryder) + testNucleusAuth(t, "StryderBadEndpoint", ``, ErrStryder) + testNucleusAuth(t, "StryderGoAway", "Go away.\n", ErrStryder) + testNucleusAuth(t, "InvalidGame", `{"token":"...","hasOnlineAccess":"1","expiry":"1234","storeUri":"https://www.origin.com/store/titanfall/titanfall-3/future-edition"}`, ErrInvalidGame) // never seen this, but test it +} + +func testNucleusAuth(t *testing.T, name, resp string, res error) { + t.Run(name, func(t *testing.T) { + buf, err := nucleusAuth(&http.Response{ + Status: "200 OK", + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(resp)), + }) + if !bytes.Equal(buf, bytes.TrimSpace([]byte(resp))) { + t.Errorf("returned response %q doesn't match original response %q", string(buf), string(resp)) + } + if res == nil { + if err != nil { + t.Errorf("unexpected error (resp %q): %v", resp, err) + } + } else { + if !errors.Is(err, res) { + t.Errorf("expected error %q, got %q", res, err) + } + } + }) +} |