Skip to content
Merged
4 changes: 2 additions & 2 deletions .github/workflows/go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ jobs:

- name: Staticcheck
run: |
go install honnef.co/go/tools/cmd/staticcheck@latest
go install honnef.co/go/tools/cmd/staticcheck@v0.7.0
staticcheck ./...

- name: Test
run: go test ./...
run: go test ./...
8 changes: 8 additions & 0 deletions xal/nsal/resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@ type TokenSource interface {
ProofKey() *ecdsa.PrivateKey
}

// TokenInvalidator discards XSTS tokens rejected by a relying party.
// It is implemented by TokenSource that supports token caching.
type TokenInvalidator interface {
// InvalidateXSTSToken discards rejected for relyingParty if it is still
// cached. The next XSTSToken call must not reuse that token.
InvalidateXSTSToken(relyingParty string, rejected *xsts.Token)
}

// ResolverConfig configures a [Resolver].
type ResolverConfig struct {
// TitleIDs lists title data sources to resolve lazily in precedence order.
Expand Down
40 changes: 38 additions & 2 deletions xal/nsal/transport.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ type Transport struct {

// RoundTrip implements [http.RoundTripper].
func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) {
if t.Resolver == nil {
return nil, errors.New("xal/nsal: Transport.RoundTrip: nil Resolver")
}

var reqBodyClosed bool
if req.Body != nil {
defer func() {
Expand All @@ -55,10 +59,14 @@ func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) {
return t.baseTransport().RoundTrip(req)
}

token, policy, err := t.TokenAndSignature(ctx, req.URL)
endpoint, policy, err := t.Resolver.Resolve(ctx, req.URL)
if err != nil {
return nil, fmt.Errorf("request XSTS token and signature: %w", err)
}
token, err := t.Resolver.src.XSTSToken(ctx, endpoint.RelyingParty)
if err != nil {
return nil, fmt.Errorf("request XSTS token and signature: request XSTS token: %w", err)
}

req2 := req.Clone(ctx)
token.SetAuthHeader(req2)
Expand All @@ -78,7 +86,35 @@ func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) {
}
}

return t.baseTransport().RoundTrip(req2)
resp, err := t.baseTransport().RoundTrip(req2)
if err != nil {
return nil, err
}
if invalidator, ok := t.Resolver.src.(TokenInvalidator); ok && tokenExpired(resp) {
invalidator.InvalidateXSTSToken(endpoint.RelyingParty, token)
}
return resp, nil
}

// tokenExpired reports whether Xbox explicitly rejected an expired token.
func tokenExpired(resp *http.Response) bool {
if resp == nil || resp.StatusCode != http.StatusUnauthorized {
return false
}
for _, value := range resp.Header.Values("WWW-Authenticate") {
for part := range strings.SplitSeq(value, ",") {
part = strings.TrimSpace(part)
if len(part) >= len("token ") && strings.EqualFold(part[:len("token ")], "token ") {
part = strings.TrimSpace(part[len("token "):])
}
name, value, ok := strings.Cut(part, "=")
if ok && strings.EqualFold(strings.TrimSpace(name), "error") &&
strings.EqualFold(strings.Trim(strings.TrimSpace(value), "\"'"), "token_expired") {
return true
}
}
}
return false
}

// TokenAndSignature resolves an XSTS token and signature policy for the given URL.
Expand Down
155 changes: 155 additions & 0 deletions xal/nsal/transport_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,133 @@ func TestTransportRoundTripSignsRequest(t *testing.T) {
}
}

func TestTransportRoundTripWithNilResolverReturnsError(t *testing.T) {
transport := &Transport{}
req, err := http.NewRequest(http.MethodGet, "https://multiplayer.minecraft.net/authentication", nil)
if err != nil {
t.Fatalf("new request: %v", err)
}

_, err = transport.RoundTrip(req)
if err == nil || !strings.Contains(err.Error(), "xal/nsal: Transport.RoundTrip: nil Resolver") {
t.Fatalf("RoundTrip error = %v, want nil Resolver error", err)
}
}

func TestTransportRoundTripInvalidatesExpiredXSTSToken(t *testing.T) {
key := mustGenerateKey(t)
stale := authorizationToken("stale")
src := &invalidatingTransportTokenSource{
transportTokenSource: transportTokenSource{token: stale, proofKey: key},
}
responseBody := &trackingBody{ReadCloser: http.NoBody}
var requests int
transport := &Transport{
Base: roundTripFunc(func(req *http.Request) (*http.Response, error) {
requests++
if requests > 1 {
t.Fatalf("unexpected request %d", requests)
}
body, err := io.ReadAll(req.Body)
if err != nil || string(body) != "payload" {
t.Fatalf("request %d body = %q, err = %v", requests, body, err)
}
if got := req.Header.Get("Authorization"); got != "XBL3.0 x=uhs;stale" {
t.Fatalf("Authorization = %q, want stale token", got)
}
return &http.Response{
StatusCode: http.StatusUnauthorized,
Header: http.Header{"Www-Authenticate": {"Token error='token_expired'"}},
Body: responseBody,
}, nil
}),
Resolver: testResolver(src),
}

req, err := http.NewRequest(http.MethodPut, "https://multiplayer.minecraft.net/authentication", strings.NewReader("payload"))
if err != nil {
t.Fatalf("new request: %v", err)
}
req.GetBody = nil
resp, err := transport.RoundTrip(req)
if err != nil {
t.Fatalf("RoundTrip: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusUnauthorized)
}
if src.invalidated != stale {
t.Fatal("invalidated token was not the rejected token")
}
if src.invalidationRelyingParty != "https://multiplayer.minecraft.net/" {
t.Fatalf("invalidation relying party = %q, want https://multiplayer.minecraft.net/", src.invalidationRelyingParty)
}
if src.calls != 1 {
t.Fatalf("XSTSToken calls = %d, want 1", src.calls)
}
if src.invalidationCalls != 1 {
t.Fatalf("InvalidateXSTSToken calls = %d, want 1", src.invalidationCalls)
}
if responseBody.closed {
t.Fatal("response body was closed before being returned")
}
}

func TestTransportRoundTripDoesNotRetryWithoutTokenInvalidator(t *testing.T) {
src := &nonInvalidatingTransportTokenSource{
token: authorizationToken("stale"),
proofKey: mustGenerateKey(t),
}
var requests int
transport := &Transport{
Base: roundTripFunc(func(*http.Request) (*http.Response, error) {
requests++
return &http.Response{
StatusCode: http.StatusUnauthorized,
Header: http.Header{"Www-Authenticate": {"Token error='token_expired'"}},
Body: http.NoBody,
}, nil
}),
Resolver: testResolver(src),
}

req, err := http.NewRequest(http.MethodGet, "https://multiplayer.minecraft.net/authentication", nil)
if err != nil {
t.Fatalf("new request: %v", err)
}
resp, err := transport.RoundTrip(req)
if err != nil {
t.Fatalf("RoundTrip: %v", err)
}
defer resp.Body.Close()
if requests != 1 {
t.Fatalf("requests = %d, want 1", requests)
}
}

func TestTokenExpired(t *testing.T) {
for name, tc := range map[string]struct {
headers []string
want bool
}{
"first parameter": {[]string{"Token error='token_expired'"}, true},
"double quoted": {[]string{`Token error="token_expired"`}, true},
"spaced equals": {[]string{`Token error = "token_expired"`}, true},
"no comma space": {[]string{"Token realm='xboxlive.com',error='token_expired'"}, true},
"later header": {[]string{"Token error='token_required'", "Token error='token_expired'"}, true},
"other error": {[]string{"Token error='token_required'"}, false},
"provider error": {[]string{"Token provider_error='token_expired'"}, false},
} {
t.Run(name, func(t *testing.T) {
resp := &http.Response{StatusCode: http.StatusUnauthorized, Header: http.Header{"Www-Authenticate": tc.headers}}
if got := tokenExpired(resp); got != tc.want {
t.Fatalf("tokenExpired = %t, want %t", got, tc.want)
}
})
}
}

func TestTransportRoundTripUsesExistingAuthorization(t *testing.T) {
src := &transportTokenSource{token: authorizationToken("unexpected")}
transport := &Transport{
Expand Down Expand Up @@ -193,6 +320,34 @@ type transportTokenSource struct {
err error
}

type nonInvalidatingTransportTokenSource struct {
token *xsts.Token
proofKey *ecdsa.PrivateKey
}

// XSTSToken returns the static token without supporting invalidation.
func (src *nonInvalidatingTransportTokenSource) XSTSToken(context.Context, string) (*xsts.Token, error) {
return src.token, nil
}

// ProofKey returns the static source proof key.
func (src *nonInvalidatingTransportTokenSource) ProofKey() *ecdsa.PrivateKey {
return src.proofKey
}

type invalidatingTransportTokenSource struct {
transportTokenSource
invalidated *xsts.Token
invalidationRelyingParty string
invalidationCalls int
}

func (src *invalidatingTransportTokenSource) InvalidateXSTSToken(relyingParty string, rejected *xsts.Token) {
src.invalidated = rejected
src.invalidationRelyingParty = relyingParty
src.invalidationCalls++
}

func (src *transportTokenSource) XSTSToken(_ context.Context, relyingParty string) (*xsts.Token, error) {
src.called = true
src.calls++
Expand Down
77 changes: 61 additions & 16 deletions xal/sisu/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ func (conf Config) New(src oauth2.TokenSource, sc *SessionConfig) *Session {
if s.xsts == nil {
s.xsts = make(map[string]*xsts.Token)
}
s.xstsGenerations = make(map[string]uint64)
return s
}

Expand Down Expand Up @@ -147,6 +148,9 @@ type Session struct {
xsts map[string]*xsts.Token
// xstsMu guards xsts tokens from concurrent read-write access.
xstsMu sync.Mutex
// xstsGenerations tracks invalidations by relying party so an overlapping
// acquisition cannot restore a rejected token.
xstsGenerations map[string]uint64

// resp is the last known response for SISU authorization request.
// It contains title, user, and an XSTS token that relies on the
Expand Down Expand Up @@ -242,30 +246,71 @@ func (s *Session) Snapshot() *Snapshot {
//
// XSTS tokens are cached per relying party and reused until expiration.
func (s *Session) XSTSToken(ctx context.Context, relyingParty string) (*xsts.Token, error) {
s.xstsMu.Lock()
token, ok := s.xsts[relyingParty]
if ok && token.Valid() {
// Re-use the cached XSTS token as possible.
return s.xstsToken(ctx, relyingParty, s.requestXSTS)
}

// xstsToken avoids caching an acquisition that overlaps an invalidation for the
// same relying party.
func (s *Session) xstsToken(ctx context.Context, relyingParty string, request func(context.Context, string) (*xsts.Token, error)) (*xsts.Token, error) {
for {
s.xstsMu.Lock()
if token := s.xsts[relyingParty]; token.Valid() {
// Re-use the cached XSTS token as possible.
s.xstsMu.Unlock()
return token, nil
}
generation := s.xstsGenerations[relyingParty]
s.xstsMu.Unlock()

token, err := request(ctx, relyingParty)
if err != nil {
return nil, err
}
if !token.Valid() {
return nil, errors.New("xal/sisu: invalid XSTS token data")
}

s.xstsMu.Lock()
if cached := s.xsts[relyingParty]; cached.Valid() {
s.xstsMu.Unlock()
return cached, nil
}
if s.xstsGenerations[relyingParty] != generation {
s.xstsMu.Unlock()
continue
}
s.xsts[relyingParty] = token
s.xstsMu.Unlock()
return token, nil
}
s.xstsMu.Unlock()
}

token, err := s.requestXSTS(ctx, relyingParty)
if err != nil {
return nil, err
}
if !token.Valid() {
return nil, errors.New("xal/sisu: invalid XSTS token data")
// InvalidateXSTSToken removes rejected from the caches for relyingParty if it
// has not already been replaced. A subsequent XSTSToken call acquires a new
// token through the normal cache path.
func (s *Session) InvalidateXSTSToken(relyingParty string, rejected *xsts.Token) {
if rejected == nil || rejected.Token == "" {
return
}

// Keep both caches behind one invalidation boundary so a default-RP
// acquisition cannot observe the new generation while still reusing s.resp.
s.xstsMu.Lock()
defer s.xstsMu.Unlock()
if cached, ok := s.xsts[relyingParty]; ok && cached.Valid() {
return cached, nil
s.respMu.Lock()
s.xstsGenerations[relyingParty]++
if sameXSTSToken(s.xsts[relyingParty], rejected) {
delete(s.xsts, relyingParty)
}
if s.resp != nil && sameXSTSToken(s.resp.AuthorizationToken, rejected) {
s.resp = nil
}
s.xsts[relyingParty] = token
return token, nil
s.respMu.Unlock()
s.xstsMu.Unlock()
}

// sameXSTSToken reports whether a and b contain the same serialized token.
func sameXSTSToken(a, b *xsts.Token) bool {
return a != nil && b != nil && a.Token == b.Token
}

// requestXSTS obtains a new XSTS token for the relying party.
Expand Down
Loading