aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--pkg/stryder/stryder.go108
-rw-r--r--pkg/stryder/stryder_test.go42
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)
+ }
+ }
+ })
+}