diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 15e83299..4f2a0fbc 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -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 ./... \ No newline at end of file + run: go test ./... diff --git a/xal/nsal/resolver.go b/xal/nsal/resolver.go index 33c79861..08360019 100644 --- a/xal/nsal/resolver.go +++ b/xal/nsal/resolver.go @@ -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. diff --git a/xal/nsal/transport.go b/xal/nsal/transport.go index 2c4d4e51..ed8006d8 100644 --- a/xal/nsal/transport.go +++ b/xal/nsal/transport.go @@ -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() { @@ -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) @@ -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. diff --git a/xal/nsal/transport_test.go b/xal/nsal/transport_test.go index 9981e397..7362cab8 100644 --- a/xal/nsal/transport_test.go +++ b/xal/nsal/transport_test.go @@ -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{ @@ -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++ diff --git a/xal/sisu/session.go b/xal/sisu/session.go index 45ea976d..3d8cdead 100644 --- a/xal/sisu/session.go +++ b/xal/sisu/session.go @@ -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 } @@ -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 @@ -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. diff --git a/xal/sisu/session_unit_test.go b/xal/sisu/session_unit_test.go index 90fbb6d5..79d0830c 100644 --- a/xal/sisu/session_unit_test.go +++ b/xal/sisu/session_unit_test.go @@ -167,6 +167,129 @@ func TestXSTSTokenDoesNotHoldCacheLockDuringTokenRequest(t *testing.T) { } } +func TestSessionInvalidatesOnlyRejectedRelyingParty(t *testing.T) { + rejected := validXSTSToken("rejected") + replacement := validXSTSToken("replacement") + other := validXSTSToken("other") + session := (Config{}).New(staticMSATokenSource{}, nil) + session.xsts[defaultRelyingParty] = rejected + session.xsts["https://example.com/"] = other + session.resp = &authorizationResponse{AuthorizationToken: rejected} + + session.InvalidateXSTSToken(defaultRelyingParty, rejected) + if _, ok := session.xsts[defaultRelyingParty]; ok { + t.Fatal("rejected token remained cached") + } + if session.resp != nil { + t.Fatal("SISU response containing rejected token remained cached") + } + if session.xsts["https://example.com/"] != other { + t.Fatal("token for another relying party was invalidated") + } + + session.xsts[defaultRelyingParty] = replacement + session.resp = &authorizationResponse{AuthorizationToken: replacement} + session.InvalidateXSTSToken(defaultRelyingParty, rejected) + if session.xsts[defaultRelyingParty] != replacement || session.resp.AuthorizationToken != replacement { + t.Fatal("late rejection removed the replacement token") + } +} + +func TestSessionDoesNotCacheTokenFetchedDuringSameRelyingPartyInvalidation(t *testing.T) { + rejected := validXSTSToken("rejected") + replacement := validXSTSToken("replacement") + session := (Config{}).New(staticMSATokenSource{}, nil) + fetchStarted, allowFetch := make(chan struct{}), make(chan struct{}) + var calls int + request := func(context.Context, string) (*xsts.Token, error) { + calls++ + if calls == 1 { + close(fetchStarted) + <-allowFetch + return rejected, nil + } + return replacement, nil + } + + type result struct { + token *xsts.Token + err error + } + done := make(chan result, 1) + go func() { + token, err := session.xstsToken(context.Background(), defaultRelyingParty, request) + done <- result{token: token, err: err} + }() + + <-fetchStarted + session.InvalidateXSTSToken(defaultRelyingParty, rejected) + close(allowFetch) + got := <-done + if got.err != nil { + t.Fatalf("XSTSToken: %v", got.err) + } + if got.token != replacement { + t.Fatal("XSTSToken returned the token fetched before invalidation") + } + if calls != 2 { + t.Fatalf("token requests = %d, want 2", calls) + } + if cached := session.xsts[defaultRelyingParty]; cached != replacement { + t.Fatal("token fetched before invalidation was restored to the cache") + } +} + +func TestSessionOtherRelyingPartyInvalidationDoesNotRestartAcquisition(t *testing.T) { + const acquiringRelyingParty = "https://example.com/acquiring" + const invalidatedRelyingParty = "https://example.com/invalidated" + acquired := validXSTSToken("acquired") + rejected := validXSTSToken("rejected") + session := (Config{}).New(staticMSATokenSource{}, nil) + session.xsts[invalidatedRelyingParty] = rejected + fetchStarted, allowFetch := make(chan struct{}), make(chan struct{}) + var calls int + request := func(context.Context, string) (*xsts.Token, error) { + calls++ + close(fetchStarted) + <-allowFetch + return acquired, nil + } + + done := make(chan struct { + token *xsts.Token + err error + }, 1) + go func() { + token, err := session.xstsToken(context.Background(), acquiringRelyingParty, request) + done <- struct { + token *xsts.Token + err error + }{token: token, err: err} + }() + + <-fetchStarted + session.InvalidateXSTSToken(invalidatedRelyingParty, rejected) + close(allowFetch) + got := <-done + if got.err != nil { + t.Fatalf("XSTSToken: %v", got.err) + } + if got.token != acquired { + t.Fatal("unrelated invalidation changed the acquired token") + } + if calls != 1 { + t.Fatalf("token requests = %d, want 1", calls) + } +} + +func validXSTSToken(value string) *xsts.Token { + return &xsts.Token{ + Token: value, + NotAfter: time.Now().Add(time.Hour), + DisplayClaims: xsts.DisplayClaims{UserInfo: []xsts.UserInfo{{}}}, + } +} + type staticMSATokenSource struct{} func (staticMSATokenSource) Token() (*oauth2.Token, error) {