aboutsummaryrefslogtreecommitdiff
path: root/pkg/stryder/stryder.go
blob: b73d608dc17a91369d6ac4d1c16b1105ac14d89b (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
// 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
}

// NucleusAuthUsername extracts the username field from the nucleus auth
// response (since October 2, 2023). This field is usually empty for
// unsuccessful responses, but is always present (if not, an error will be
// returned). If resp is empty or invalid, an error will be returned.
func NucleusAuthUsername(resp []byte) (string, error) {
	var obj struct {
		Username *string `json:"userName,omitempty"`
	}
	if len(resp) == 0 {
		return "", fmt.Errorf("empty or missing nucleus auth response")
	}
	if err := json.Unmarshal(resp, &obj); err != nil {
		return "", fmt.Errorf("%w: invalid nucleus auth response json: %v", ErrStryder, err)
	}
	if obj.Username == nil {
		return "", fmt.Errorf("missing userName field in nucleus auth response %q", string(resp))
	}
	return *obj.Username, nil
}

func castOr[T any](v any, d T) T {
	if x, ok := v.(T); ok {
		return x
	}
	return d
}