arblant
firefox-vpn-client / core / internal / proxy / revoked_test.go
revoked_test.go177 lines · 6.4 KB
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 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177
package proxy

import (
	"context"
	"encoding/json"
	"errors"
	"net/http"
	"strings"
	"sync/atomic"
	"testing"
	"time"

	"github.com/armanlife/firefox-vpn-client/core/internal/accounts"
)

// All credentials in this file are local fixtures; the control plane
// transport rejects every request it does not recognise.

const invalidTokenBody = `{"code":401,"errno":110,"message":"Invalid authentication token in request signature"}`

// revokedFixture answers FxA and Guardian for accounts whose refresh token
// Mozilla revoked: the device endpoint and the refresh refuse the stored
// tokens, a sign-in issues "new-access"/"new-refresh", and Guardian
// accepts only "new-access" unless acceptOld.
func revokedFixture(t *testing.T, verified bool, acceptOld bool) (logins, devices *atomic.Int32) {
	t.Helper()
	logins, devices = new(atomic.Int32), new(atomic.Int32)
	setCoreControlPlane(t, func(r *http.Request) (*http.Response, error) {
		bearer := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
		if r.URL.Host == "api.accounts.firefox.com" {
			var body map[string]any
			if r.Body != nil {
				_ = json.NewDecoder(r.Body).Decode(&body)
			}
			switch {
			case r.URL.Path == "/v1/account/device" && bearer == "new-refresh":
				devices.Add(1)
				return coreResponse(r, http.StatusOK, `{"id":"dev-new","name":"Firefox's built-in VPN"}`, nil), nil
			case r.URL.Path == "/v1/account/device":
				return coreResponse(r, http.StatusUnauthorized, invalidTokenBody, nil), nil
			case r.URL.Path == "/v1/account/login":
				logins.Add(1)
				v := "true"
				if !verified {
					v = `false,"verificationMethod":"email-otp"`
				}
				return coreResponse(r, http.StatusOK, `{"uid":"u1","sessionToken":"`+strings.Repeat("ab", 32)+`","verified":`+v+`}`, nil), nil
			case r.URL.Path == "/v1/session/destroy":
				return coreResponse(r, http.StatusOK, `{}`, nil), nil
			case r.URL.Path == "/v1/oauth/token" && body["grant_type"] == "refresh_token":
				return coreResponse(r, http.StatusUnauthorized, invalidTokenBody, nil), nil
			case r.URL.Path == "/v1/oauth/token":
				return coreResponse(r, http.StatusOK, `{"access_token":"new-access","refresh_token":"new-refresh","expires_in":3600,"token_type":"bearer"}`, nil), nil
			}
			return nil, errors.New("unexpected FxA request " + r.URL.Path)
		}
		if bearer == "new-access" || (acceptOld && strings.HasPrefix(bearer, "access-")) {
			if r.Method == http.MethodHead {
				return coreResponse(r, http.StatusOK, "", quotaHeader(1)), nil
			}
			return coreResponse(r, http.StatusOK, `{"token":"`+accountJWT("a")+`"}`, quotaHeader(1)), nil
		}
		return coreResponse(r, http.StatusUnauthorized, `{"error":"invalid token"}`, nil), nil
	})
	DeviceRegistration.Store(true)
	t.Cleanup(func() { DeviceRegistration.Store(false) })
	return logins, devices
}

// Device registration is where a revoked refresh token shows first (in the
// user's test, at every start for 24 hours while Guardian kept accepting
// the access token): the account signs in again at once.
func TestRevokedRefreshTokenSignsInAgainAtOnce(t *testing.T) {
	pool, accs := newTestAccountPool(t, "a")
	id := accs["a"].ID
	if err := pool.store.SetPassword(id, "secret"); err != nil {
		t.Fatal(err)
	}
	logins, devices := revokedFixture(t, true, true)

	pool.EnsureDevice(context.Background(), id)

	if logins.Load() != 1 {
		t.Fatalf("expected one automatic sign-in, got %d", logins.Load())
	}
	if creds, _ := pool.store.Credentials(id); creds.RefreshToken != "new-refresh" {
		t.Fatal("the new tokens must be stored")
	}
	waitForCondition(t, 5*time.Second, func() bool {
		acc, err := pool.store.Get(id)
		return err == nil && acc.DeviceID == "dev-new" && devices.Load() == 1
	})
	if a := accountStatus(t, pool, id); a.Status != accounts.StatusReady {
		t.Fatalf("status %s %q", a.Status, a.Reason)
	}
}

// When the sign-in needs a confirmation code, the account is marked for
// "Sign in again" so the user hears of it now, unless it is the only
// account the proxy can use: its access token still works until it
// expires.
func TestRevokedRefreshTokenNeedingCode(t *testing.T) {
	t.Run("another account takes over", func(t *testing.T) {
		pool, accs := newTestAccountPool(t, "a", "b")
		id := accs["a"].ID
		if err := pool.mgr.Activate(id); err != nil {
			t.Fatal(err)
		}
		if err := pool.store.SetPassword(id, "secret"); err != nil {
			t.Fatal(err)
		}
		revokedFixture(t, false, true)
		pool.EnsureDevice(context.Background(), id)
		if a := accountStatus(t, pool, id); a.Status != accounts.StatusReauthRequired {
			t.Fatalf("status %s %q", a.Status, a.Reason)
		}
	})
	t.Run("the only account keeps working", func(t *testing.T) {
		pool, accs := newTestAccountPool(t, "a")
		id := accs["a"].ID
		if err := pool.mgr.Activate(id); err != nil {
			t.Fatal(err)
		}
		if err := pool.store.SetPassword(id, "secret"); err != nil {
			t.Fatal(err)
		}
		revokedFixture(t, false, true)
		pool.EnsureDevice(context.Background(), id)
		if a := accountStatus(t, pool, id); a.Status != accounts.StatusReady {
			t.Fatalf("status %s %q", a.Status, a.Reason)
		}
	})
}

// A 401 in the quota check renews at once (refresh, then a new sign-in)
// instead of waiting for the next scheduled renewal.
func TestQuotaCheckRefusedTokenRenewsNow(t *testing.T) {
	pool, accs := newTestAccountPool(t, "a")
	id := accs["a"].ID
	if err := pool.mgr.Activate(id); err != nil {
		t.Fatal(err)
	}
	if err := pool.store.SetPassword(id, "secret"); err != nil {
		t.Fatal(err)
	}
	authA, err := pool.AuthFor(accs["a"])
	if err != nil {
		t.Fatal(err)
	}
	logins, devices := revokedFixture(t, true, false)

	session := &fakeProxySession{token: "pass-a"}
	controller := &Controller{
		guardian:      "https://guardian.invalid",
		accounts:      pool,
		auth:          authA,
		quotaCheckNow: make(chan struct{}, 1),
		done:          make(chan struct{}),
		current:       &managedSession{session: session, rawToken: "pass-a", expiresAt: time.Now().Add(10 * time.Minute), accepting: true},
		sessionFactory: func(string) (proxySession, error) {
			return nil, errors.New("a renewal must not rebuild the upstream session")
		},
	}
	controller.checkQuota()

	if logins.Load() != 1 {
		t.Fatalf("expected one automatic sign-in, got %d", logins.Load())
	}
	if got := session.currentToken(); got != accountJWT("a") {
		t.Fatal("expected the session to carry the new pass")
	}
	// The sign-in registers the new token's device in the background.
	waitForCondition(t, 5*time.Second, func() bool {
		acc, err := pool.store.Get(id)
		return err == nil && acc.DeviceID == "dev-new" && devices.Load() == 1
	})
}