fix(session): block challenged session generations

This commit is contained in:
zarzet
2026-07-30 13:09:42 +07:00
parent 177a176218
commit 113ad7ed9b
3 changed files with 158 additions and 13 deletions
+6 -1
View File
@@ -115,7 +115,12 @@ gateway session state for the exact `SESSION_INVALID/bootstrap_session` or
applies bounded `Retry-After` handling to retryable `PROVIDER_UNAVAILABLE`
responses. `403 REQUEST_AUTH_INVALID` preserves the session and only retries
once when a newer session generation is already available. BYOA
reauthentication remains a separate provider action.
reauthentication remains a separate provider action. An active generation that
receives canonical `428 VERIFY_REQUIRED` is blocked in the shared coordinator,
so later requests join the same verification flow instead of hitting the
gateway repeatedly. Every retry is a newly signed request with a fresh
timestamp and nonce; `retryable: true` therefore also asserts that any ticket
attached to the operation is safe/idempotent for that retry.
Do not use legacy spellings such as `display_name`, `types`,
`permissions.network.domains`, or an object for `permissions.network`.
+72 -10
View File
@@ -28,7 +28,7 @@ const signedSessionRefreshSkew = time.Hour
const (
signedSessionExchangeMaxAttempts = 3
signedSessionMaxRetryAfter = 5 * time.Minute
signedSessionMaxSessionRetries = 2
signedSessionMaxSessionRetries = 1
signedSessionMaxProviderRetries = 2
signedSessionProviderRetryDelay = time.Second
)
@@ -39,6 +39,7 @@ var (
signedSessionCoordinators sync.Map
signedSessionRetryWait = time.Sleep
signedSessionProviderWait = sleepRetry
signedSessionRequestNow = time.Now
)
// signedSessionCoordinator serializes authentication state for every runtime
@@ -53,6 +54,7 @@ type signedSessionCoordinator struct {
challengeCreatedAt time.Time
pendingExtensionIDs map[string]struct{}
completedGrantHash string
blockedGeneration string
}
func (r *extensionRuntime) signedSessionCoordinator(config SignedSessionConfig) (*signedSessionCoordinator, error) {
@@ -90,6 +92,27 @@ func (c *signedSessionCoordinator) activeChallenge() bool {
time.Since(c.challengeCreatedAt) < pendingAuthRequestTTL
}
func signedSessionGeneration(record *signedSessionRecord) string {
if record == nil || record.SessionID == "" || record.SessionSecret == "" {
return ""
}
sum := sha256.Sum256([]byte(record.SessionID + "\n" + record.SessionSecret))
return hex.EncodeToString(sum[:])
}
func (c *signedSessionCoordinator) blockGeneration(record *signedSessionRecord) {
c.blockedGeneration = signedSessionGeneration(record)
}
func (c *signedSessionCoordinator) generationIsBlocked(record *signedSessionRecord) bool {
generation := signedSessionGeneration(record)
return generation != "" && generation == c.blockedGeneration
}
func (c *signedSessionCoordinator) clearBlockedGeneration() {
c.blockedGeneration = ""
}
type signedSessionRecord struct {
InstallID string `json:"install_id"`
SessionID string `json:"session_id,omitempty"`
@@ -385,7 +408,8 @@ func (r *extensionRuntime) preflightSignedSession() (bool, error) {
if err != nil {
return false, err
}
if signedSessionRecordIsUsable(record) {
if signedSessionRecordIsUsable(record) &&
!coordinator.generationIsBlocked(record) {
return false, nil
}
@@ -423,14 +447,16 @@ func (r *extensionRuntime) signedSessionStatus(call goja.FunctionCall) goja.Valu
if err != nil {
return r.vm.ToValue(map[string]any{"authenticated": false, "error": err.Error()})
}
authenticated := signedSessionRecordIsUsable(record)
blocked := coordinator.generationIsBlocked(record)
authenticated := signedSessionRecordIsUsable(record) && !blocked
return r.vm.ToValue(map[string]any{
"authenticated": authenticated,
"expires_at": record.ExpiresAt,
"install_id": record.InstallID,
"session_id": record.SessionID,
"app_version": config.AppVersion,
"platform": config.Platform,
"authenticated": authenticated,
"verification_required": blocked,
"expires_at": record.ExpiresAt,
"install_id": record.InstallID,
"session_id": record.SessionID,
"app_version": config.AppVersion,
"platform": config.Platform,
})
}
@@ -452,6 +478,7 @@ func (r *extensionRuntime) signedSessionClear(call goja.FunctionCall) goja.Value
if err := r.saveSignedSession(config, record); err != nil {
return r.vm.ToValue(map[string]any{"success": false, "error": err.Error()})
}
coordinator.clearBlockedGeneration()
coordinator.clearChallenge()
ClearPendingAuthRequest(r.extensionID)
return r.vm.ToValue(map[string]any{"success": true})
@@ -510,6 +537,7 @@ func (r *extensionRuntime) exchangeSignedSessionGrantLocked(
// instead of consuming the grant again.
if coordinator.completedGrantHash == grantHash &&
signedSessionRecordIsUsable(record) {
coordinator.clearBlockedGeneration()
coordinator.clearChallenge()
return nil
}
@@ -584,6 +612,7 @@ func (r *extensionRuntime) exchangeSignedSessionGrantLocked(
return err
}
coordinator.completedGrantHash = grantHash
coordinator.clearBlockedGeneration()
coordinator.clearChallenge()
return nil
}
@@ -631,6 +660,33 @@ func (r *extensionRuntime) signedSessionFetch(call goja.FunctionCall) goja.Value
}
return r.vm.ToValue(map[string]any{"ok": false, "error": err.Error()})
}
if coordinator.generationIsBlocked(record) {
authURL, verificationErr := r.startSignedSessionVerificationLocked(
config,
coordinator,
"signed-fetch-blocked-generation",
)
if authURL != "" {
coordinator.mu.Unlock()
return r.signedSessionVerificationRequiredValue(authURL)
}
if verificationErr != nil {
coordinator.mu.Unlock()
return r.vm.ToValue(map[string]any{"ok": false, "error": verificationErr.Error()})
}
record, err = r.loadSignedSession(config)
if err != nil {
coordinator.mu.Unlock()
return r.vm.ToValue(map[string]any{"ok": false, "error": err.Error()})
}
if !signedSessionRecordIsUsable(record) || coordinator.generationIsBlocked(record) {
coordinator.mu.Unlock()
return r.vm.ToValue(map[string]any{
"ok": false,
"error": "verification_required: signed-session generation is blocked",
})
}
}
coordinator.mu.Unlock()
// A request that loses a race with a successful grant exchange may return a
@@ -734,6 +790,7 @@ func (r *extensionRuntime) signedSessionFetch(call goja.FunctionCall) goja.Value
// VERIFY_REQUIRED is an explicit challenge request, not a revocation.
// Only SESSION_INVALID is allowed to clear the current gateway session.
if gatewayAction == "bootstrap_session" && sameSignedSession(latest, record) {
coordinator.clearBlockedGeneration()
latest.SessionID = ""
latest.SessionSecret = ""
latest.ExpiresAt = ""
@@ -741,6 +798,10 @@ func (r *extensionRuntime) signedSessionFetch(call goja.FunctionCall) goja.Value
coordinator.mu.Unlock()
return r.vm.ToValue(map[string]any{"ok": false, "error": saveErr.Error()})
}
} else if gatewayAction == "verify" && sameSignedSession(latest, record) {
// Stop subsequent requests from repeatedly hitting the gateway with
// a generation that is known to require human verification.
coordinator.blockGeneration(record)
}
authURL, verificationErr := r.startSignedSessionVerificationLocked(
config,
@@ -999,6 +1060,7 @@ func (r *extensionRuntime) startSignedSessionVerificationLocked(
if err := r.saveSignedSession(config, record); err != nil {
return "", fmt.Errorf("save bootstrapped signed session: %w", err)
}
coordinator.clearBlockedGeneration()
coordinator.clearChallenge()
return "", nil
}
@@ -1082,7 +1144,7 @@ func (r *extensionRuntime) doSignedSessionRequest(
if err != nil {
return nil, nil, nil, err
}
ts := time.Now().UTC().Format("2006-01-02T15:04:05.000Z")
ts := signedSessionRequestNow().UTC().Format("2006-01-02T15:04:05.000Z")
nonce := randomHex(12)
bodyHashBytes := sha256.Sum256(body)
bodyHash := hex.EncodeToString(bodyHashBytes[:])
+80 -2
View File
@@ -1103,7 +1103,8 @@ func TestSignedSessionFetchCanonicalVerifyDoesNotClearSession(t *testing.T) {
calls := 0
transport := roundTripFunc(func(req *http.Request) (*http.Response, error) {
calls++
if strings.Contains(req.URL.Path, "/bootstrap") {
switch {
case strings.Contains(req.URL.Path, "/bootstrap"):
payload, _ := json.Marshal(signedSessionExchangeResponse{
AuthURL: "https://auth.example.com/verify",
})
@@ -1113,6 +1114,25 @@ func TestSignedSessionFetchCanonicalVerifyDoesNotClearSession(t *testing.T) {
Body: io.NopCloser(strings.NewReader(string(payload))),
Request: req,
}, nil
case strings.Contains(req.URL.Path, "/session/exchange"):
payload, _ := json.Marshal(signedSessionExchangeResponse{
SessionID: "sess-after-verify",
SessionSecret: "secret-after-verify",
ExpiresAt: time.Now().Add(time.Hour).UTC().Format(time.RFC3339),
})
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(string(payload))),
Request: req,
}, nil
case req.Header.Get("X-Sig-Session") == "sess-after-verify":
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(`{"ok":true}`)),
Request: req,
}, nil
}
return &http.Response{
StatusCode: http.StatusPreconditionRequired,
@@ -1146,6 +1166,36 @@ func TestSignedSessionFetchCanonicalVerifyDoesNotClearSession(t *testing.T) {
if reloaded.SessionID != "sess-verified" || reloaded.SessionSecret != "secret-sess-verified" {
t.Fatalf("VERIFY_REQUIRED cleared the valid gateway session: %+v", reloaded)
}
blockedResult := runtime.signedSessionFetch(call).Export().(map[string]any)
if blockedResult["needsVerification"] != true {
t.Fatalf("blocked generation did not join verification: %+v", blockedResult)
}
if calls != 2 {
t.Fatalf("blocked generation reached the gateway again: calls=%d", calls)
}
verificationRequired, err := runtime.preflightSignedSession()
if err != nil || !verificationRequired {
t.Fatalf("preflight did not preserve the blocked verification state: required=%v err=%v", verificationRequired, err)
}
if calls != 2 {
t.Fatalf("blocked preflight reached the gateway again: calls=%d", calls)
}
status := runtime.signedSessionStatus(goja.FunctionCall{}).Export().(map[string]any)
if status["authenticated"] != false || status["verification_required"] != true {
t.Fatalf("blocked generation status is incorrect: %+v", status)
}
if err := runtime.exchangeSignedSessionGrant("grant-after-verify"); err != nil {
t.Fatalf("exchange grant: %v", err)
}
afterVerification := runtime.signedSessionFetch(call).Export().(map[string]any)
if afterVerification["ok"] != true {
t.Fatalf("new verified generation remained blocked: %+v", afterVerification)
}
if calls != 4 {
t.Fatalf("network calls = %d, want 428, bootstrap, exchange, success", calls)
}
}
func TestSignedSessionFetchRequestAuthInvalidPreservesSession(t *testing.T) {
@@ -1319,16 +1369,32 @@ func TestSignedSessionFetchProviderContractsNeverClearSession(t *testing.T) {
t.Run("temporary provider failure retries with Retry-After", func(t *testing.T) {
previousWait := signedSessionProviderWait
previousNow := signedSessionRequestNow
var waits []time.Duration
signedSessionProviderWait = func(_ context.Context, delay time.Duration) error {
waits = append(waits, delay)
return nil
}
t.Cleanup(func() { signedSessionProviderWait = previousWait })
nextRequestTime := time.Date(2026, time.July, 30, 12, 0, 0, 0, time.UTC)
signedSessionRequestNow = func() time.Time {
current := nextRequestTime
nextRequestTime = nextRequestTime.Add(time.Second)
return current
}
t.Cleanup(func() {
signedSessionProviderWait = previousWait
signedSessionRequestNow = previousNow
})
calls := 0
nonces := make(map[string]struct{})
timestamps := make(map[string]struct{})
signatures := make(map[string]struct{})
transport := roundTripFunc(func(req *http.Request) (*http.Response, error) {
calls++
nonces[req.Header.Get("X-Sig-Nonce")] = struct{}{}
timestamps[req.Header.Get("X-Sig-Timestamp")] = struct{}{}
signatures[req.Header.Get("X-Sig-Signature")] = struct{}{}
if calls <= signedSessionMaxProviderRetries {
return &http.Response{
StatusCode: http.StatusServiceUnavailable,
@@ -1367,6 +1433,18 @@ func TestSignedSessionFetchProviderContractsNeverClearSession(t *testing.T) {
t.Fatalf("Retry-After header was not authoritative: waits=%v", waits)
}
}
wantSignedAttempts := signedSessionMaxProviderRetries + 1
if len(nonces) != wantSignedAttempts ||
len(timestamps) != wantSignedAttempts ||
len(signatures) != wantSignedAttempts {
t.Fatalf(
"provider retries reused signed request headers: nonces=%d timestamps=%d signatures=%d want=%d",
len(nonces),
len(timestamps),
len(signatures),
wantSignedAttempts,
)
}
reloaded, err := runtime.loadSignedSession(resolved)
if err != nil {
t.Fatal(err)