Skip to content

Commit 861df3f

Browse files
committed
fix(golang): finish a device session's refresh in the background after its caller gives up, so a rotated refresh token is kept
The token service rotates the refresh token when it answers a device session's refresh. A refresh cancelled with its caller after the service answered dropped the new token, and the next refresh sent one the service had retired, which it rejects: the strategy could not refresh again without a new login. This is the failure stack-auth's native refresh now avoids by finishing the refresh in a task after its caller leaves (#1172); the Go auth guest runs that refresh inline in WebAssembly, so the Go host has to do the same. Strategy.Token now runs its work on a goroutine that holds the strategy's lock until it is done, and returns to a caller whose context ends at once, without making a copy of a token nobody will read. The refresh call's context is marked, and only its requests run under guest.Detach of the caller's context with a grace of 10 seconds: the refresh finishes, the guest saves what came back, and the next Token reads it. Past the grace it fails as a transport failure, well inside the 30-second backstop, so the profile stays open. Close waits for such a refresh, so its rotated token is saved. Every other request to the token service, an access-key or OIDC exchange, rotates nothing and is still cancelled with its caller, as is a ZeroKMS request, so a cancelled NewClient or a shutdown does not wait on them. Refs #1169.
1 parent 4fa1ee2 commit 861df3f

9 files changed

Lines changed: 254 additions & 40 deletions

File tree

‎languages/golang/CHANGELOG.md‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -82,11 +82,13 @@ listed under **Breaking**. Releases are tagged `languages/golang/vX.Y.Z`.
8282
or the `auth` profile behind its strategy, for every later caller, and a
8383
request whose deadline passed while it waited for the client closed it
8484
before running. Now a call waiting for the client leaves the queue with
85-
the context's error; a ZeroKMS request or a token fetch in flight is
86-
cancelled, and the error matches both the context's error and
87-
`ErrTransport`; and the client stays open. A client or profile still
88-
closes itself (`ErrState`) when the guest traps, or when a call runs on
89-
for 30 seconds after its context ended.
85+
the context's error; a ZeroKMS request in flight is cancelled, and the
86+
error matches both the context's error and `ErrTransport`; a device
87+
session's refresh in flight finishes in the background, for up to 10
88+
seconds, so a rotated refresh token is never lost; and the client stays
89+
open. A client or profile still closes itself (`ErrState`) when the
90+
guest traps, or when a call runs on for 30 seconds after its context
91+
ended.
9092
- **stashgen refuses a match index on an index-only field**
9193
(`stash:"name,index=match"` with no `encrypt`). A match query's rows are
9294
checked by decrypting them, and such a field stores no ciphertext.

‎languages/golang/auth/cancel_test.go‎

Lines changed: 129 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,15 @@ package auth
22

33
import (
44
"context"
5+
"encoding/json"
56
"errors"
67
"fmt"
78
"io"
89
"net/http"
910
"net/http/httptest"
10-
"strings"
11+
"os"
12+
"path/filepath"
13+
"sync"
1114
"sync/atomic"
1215
"testing"
1316
"time"
@@ -22,25 +25,40 @@ import (
2225
func tokenServer(t *testing.T, hold *atomic.Bool) *httptest.Server {
2326
t.Helper()
2427
var jwt string
25-
// A request the client never cancels is let go at the end, so a
26-
// failure reports itself rather than hanging the server's Close.
28+
// The server notices a client that hung up a moment after the client
29+
// has gone: it reads the closed connection on a goroutine of its own.
30+
// So the test's end waits for each held request to see its
31+
// cancellation, for a bounded time, before the server is closed (which
32+
// would end the request's context itself and hide a request the client
33+
// never cancelled).
34+
var held sync.WaitGroup
2735
done := make(chan struct{})
2836
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2937
if hold.Load() {
38+
held.Add(1)
39+
defer held.Done()
3040
// The server notices a client that hung up only once the body
3141
// is read.
3242
_, _ = io.Copy(io.Discard, r.Body)
3343
select {
3444
case <-r.Context().Done():
45+
return
3546
case <-done:
47+
}
48+
select {
49+
case <-r.Context().Done():
50+
case <-time.After(5 * time.Second):
3651
t.Error("a held request was never cancelled")
3752
}
3853
return
3954
}
4055
fmt.Fprintf(w, `{"accessToken":%q,"expiry":%d}`, jwt, time.Now().Add(time.Hour).Unix())
4156
}))
4257
t.Cleanup(server.Close)
43-
t.Cleanup(func() { close(done) })
58+
t.Cleanup(func() {
59+
close(done)
60+
held.Wait()
61+
})
4462
jwt = testJWT(t, server.URL)
4563
return server
4664
}
@@ -66,13 +84,114 @@ func TestATokenFetchCancelledFailsOnlyItself(t *testing.T) {
6684
if _, err := strategy.Token(ctx); !errors.Is(err, context.DeadlineExceeded) {
6785
t.Fatalf("cancelled Token: %v, want the deadline", err)
6886
}
69-
if profile.root.closed || profile.root.inst.module.IsClosed() {
70-
t.Fatal("the profile was closed")
71-
}
7287
hold.Store(false)
88+
// The next Token waits for the abandoned one to finish.
7389
if token, err := strategy.Token(context.Background()); err != nil || token == "" {
7490
t.Fatalf("the next Token: %q, %v", token, err)
7591
}
92+
if profile.root.closed || profile.root.inst.module.IsClosed() {
93+
t.Fatal("the profile was closed")
94+
}
95+
}
96+
97+
// An access-key exchange rotates nothing, so it ends with its caller: the
98+
// held request is cancelled at the caller's deadline, not run on for the
99+
// refresh grace, and Close does not wait for it.
100+
func TestAnAccessKeyExchangeEndsWithItsCaller(t *testing.T) {
101+
guestOrSkip(t)
102+
var hold atomic.Bool
103+
server := tokenServer(t, &hold)
104+
profile, err := Open(context.Background(), t.TempDir())
105+
if err != nil {
106+
t.Fatal(err)
107+
}
108+
defer profile.Close()
109+
strategy, err := profile.AccessKey(context.Background(), testCRN, "CSAKtestKeyId.testKeySecret", WithBaseURL(server.URL))
110+
if err != nil {
111+
t.Fatal(err)
112+
}
113+
114+
hold.Store(true)
115+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
116+
defer cancel()
117+
if _, err := strategy.Token(ctx); !errors.Is(err, context.DeadlineExceeded) {
118+
t.Fatalf("cancelled Token: %v, want the deadline", err)
119+
}
120+
// The grace is 10 seconds; a Close that waited for it would take that.
121+
start := time.Now()
122+
if err := strategy.Close(); err != nil {
123+
t.Fatal(err)
124+
}
125+
if waited := time.Since(start); waited > 2*time.Second {
126+
t.Fatalf("Close took %v: the exchange ran on past its caller", waited)
127+
}
128+
}
129+
130+
// A device session's refresh is the request that rotates a credential: the
131+
// service answers with a new refresh token and retires the old one. A
132+
// caller that gives up returns at once, but the refresh runs on, and what
133+
// it brings is saved. The next Token is served from it with no second
134+
// request, and the profile on disk holds the rotated refresh token.
135+
func TestARefreshAnsweredAfterItsCallerGaveUpIsKept(t *testing.T) {
136+
guestOrSkip(t)
137+
dir, _ := expiredDeviceProfile(t)
138+
var requests atomic.Int32
139+
answered := make(chan struct{})
140+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
141+
if requests.Add(1) > 1 {
142+
// The old refresh token is retired once the service answers.
143+
w.WriteHeader(http.StatusBadRequest)
144+
fmt.Fprint(w, `{"error":"invalid_grant"}`)
145+
return
146+
}
147+
_, _ = io.Copy(io.Discard, r.Body)
148+
time.Sleep(150 * time.Millisecond)
149+
fmt.Fprint(w, `{"access_token":"fresh","token_type":"Bearer","expires_in":3600,"refresh_token":"refresh-2"}`)
150+
close(answered)
151+
}))
152+
t.Cleanup(server.Close)
153+
profile, err := Open(context.Background(), dir)
154+
if err != nil {
155+
t.Fatal(err)
156+
}
157+
defer profile.Close()
158+
ws, err := profile.WorkspaceStore(context.Background(), wsA)
159+
if err != nil {
160+
t.Fatal(err)
161+
}
162+
strategy, err := ws.DeviceSession(context.Background(), WithBaseURL(server.URL))
163+
if err != nil {
164+
t.Fatal(err)
165+
}
166+
defer strategy.Close()
167+
168+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
169+
defer cancel()
170+
if _, err := strategy.Token(ctx); !errors.Is(err, context.DeadlineExceeded) {
171+
t.Fatalf("cancelled Token: %v, want the deadline", err)
172+
}
173+
select {
174+
case <-answered:
175+
t.Fatal("the cancelled Token waited for the service's answer")
176+
default:
177+
}
178+
if token, err := strategy.Token(context.Background()); err != nil || token != "fresh" {
179+
t.Fatalf("the next Token: %q, %v, want the refreshed token", token, err)
180+
}
181+
if n := requests.Load(); n != 1 {
182+
t.Fatalf("%d refresh requests, want 1: the late answer was dropped", n)
183+
}
184+
data, err := os.ReadFile(filepath.Join(dir, "workspaces", wsA, "auth.json"))
185+
if err != nil {
186+
t.Fatal(err)
187+
}
188+
var stored map[string]any
189+
if err := json.Unmarshal(data, &stored); err != nil {
190+
t.Fatal(err)
191+
}
192+
if stored["refresh_token"] != "refresh-2" {
193+
t.Fatalf("stored refresh token = %v, want the rotated one", stored["refresh_token"])
194+
}
76195
}
77196

78197
func TestATokenRequestWhoseContextEndsInTheQueueNeverReachesTheGuest(t *testing.T) {
@@ -114,7 +233,7 @@ func TestATokenRequestWhoseContextEndsInTheQueueNeverReachesTheGuest(t *testing.
114233
}
115234

116235
// The backstop: a call still running long after its caller gave up closes
117-
// the profile, and says so.
236+
// the profile. The caller has returned by then; the next call is ErrState.
118237
func TestATokenFetchThatRunsOnAfterItsContextEndsClosesTheProfile(t *testing.T) {
119238
guestOrSkip(t)
120239
profile, err := Open(context.Background(), t.TempDir(), WithRoundTripper(roundTripFunc(func(*http.Request) (*http.Response, error) {
@@ -134,8 +253,8 @@ func TestATokenFetchThatRunsOnAfterItsContextEndsClosesTheProfile(t *testing.T)
134253
profile.root.stuck = 20 * time.Millisecond
135254
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
136255
defer cancel()
137-
if _, err := strategy.Token(ctx); err == nil || !strings.Contains(err.Error(), "the profile is closed") {
138-
t.Fatalf("stuck Token: %v, want the profile closed", err)
256+
if _, err := strategy.Token(ctx); !errors.Is(err, context.DeadlineExceeded) {
257+
t.Fatalf("stuck Token: %v, want the deadline", err)
139258
}
140259
if _, err := strategy.Token(context.Background()); !errors.Is(err, ErrState) {
141260
t.Fatalf("a Token after the close: %v, want ErrState", err)

‎languages/golang/auth/doc.go‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,14 @@
4242
// A call whose context ends, [Strategy.Token] included, fails with the
4343
// context's error and leaves the store and its strategies open: a request
4444
// cancelled while its token is exchanged or refreshed does not stop the
45-
// encrypt clients that read tokens from the same store. The store closes
46-
// itself, and every later call is [ErrState], only when its guest traps or
47-
// a call runs on for 30 seconds after its context ended.
45+
// encrypt clients that read tokens from the same store. A request to the
46+
// token service is cancelled with its caller, except a device session's
47+
// refresh: the service rotates the refresh token when it answers one, so
48+
// the caller returns at once while the refresh runs on, for up to 10
49+
// seconds, and the store keeps what it sends back for the next call.
50+
// [Strategy.Close] waits for such a refresh. The store closes itself, and
51+
// every later call is [ErrState], only when its guest traps or a call runs
52+
// on for 30 seconds after its context ended.
4853
//
4954
// # Why a second guest
5055
//

‎languages/golang/auth/store.go‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,9 @@ func RequireLockedMemory() Option {
5757
// A call whose context ends fails with the context's error, and only that
5858
// call: a request cancelled while its token is fetched or refreshed never
5959
// closes the profile, or the strategies and encrypt clients that read
60-
// tokens from it. The profile closes itself, and every later call is
60+
// tokens from it. A device session's refresh already sent finishes in the
61+
// background, for up to 10 seconds, so a rotated refresh token is never
62+
// lost (see refreshGrace). The profile closes itself, and every later call is
6163
// [ErrState], only when the guest's state is unknown: it trapped, or it
6264
// went on running long after its caller gave up, which is a bug.
6365
type ProfileStore struct {
@@ -235,7 +237,8 @@ func (s *ProfileStore) call(ctx context.Context, fn export, args ...string) ([]b
235237
//
236238
// The caller's context bounds the wait for the lock, as encrypt's
237239
// Client.call does, and the guest runs under guest.Detach, so a caller
238-
// that gives up cancels its token request without closing the profile.
240+
// that gives up never closes the profile. A refresh request runs on for
241+
// refreshGrace, so the guest keeps the rotated token the service sends.
239242
func (s *ProfileStore) callArgs(ctx context.Context, fn export, args ...guest.Arg) ([]byte, error) {
240243
r := s.root
241244
if err := r.mu.Acquire(ctx); err != nil {

‎languages/golang/auth/strategy.go‎

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -186,27 +186,58 @@ func (s *Strategy) Token(ctx context.Context) (string, error) {
186186
if err := s.mu.Acquire(ctx); err != nil {
187187
return "", err
188188
}
189-
defer s.mu.Unlock()
190189
if s.closed {
190+
s.mu.Unlock()
191191
return "", ErrState
192192
}
193-
ctx, status := withAuthHTTPStatus(ctx)
194-
token, err := s.token(ctx)
195-
return token, status.wrap(err)
193+
// The work runs on its own goroutine, which holds the lock until it is
194+
// done, so a caller that gives up returns at once. A device session's
195+
// refresh already sent finishes there, and the guest keeps what the
196+
// service sends (see refreshGrace): a rotated refresh token is never
197+
// lost. The token it brings is dropped; the next Token reads it. Any
198+
// other request ends with the caller.
199+
type result struct {
200+
token string
201+
err error
202+
}
203+
done := make(chan result, 1)
204+
go func() {
205+
defer s.mu.Unlock()
206+
ctx, status := withAuthHTTPStatus(ctx)
207+
token, err := s.token(ctx)
208+
done <- result{token, status.wrap(err)}
209+
}()
210+
select {
211+
case r := <-done:
212+
return r.token, r.err
213+
case <-ctx.Done():
214+
// A call that finished as the context ended still answers.
215+
select {
216+
case r := <-done:
217+
return r.token, r.err
218+
default:
219+
return "", ctx.Err()
220+
}
221+
}
196222
}
197223

198224
// token is Token under the strategy's lock: a read, then a locked refresh
199225
// for a device session that needs one.
200226
func (s *Strategy) token(ctx context.Context) (string, error) {
201-
call := func(export func(*instance) api.Function) (string, error) {
227+
call := func(ctx context.Context, export func(*instance) api.Function) (string, error) {
202228
out, err := s.store.callArgs(ctx, export, guest.BufArg([]byte(s.handle)))
203229
if err != nil {
204230
return "", err
205231
}
206232
defer guest.Wipe(out)
233+
// A caller that has left gets no copy: a string cannot be wiped,
234+
// and the next Token reads the guest's.
235+
if err := ctx.Err(); err != nil {
236+
return "", err
237+
}
207238
return string(out), nil
208239
}
209-
token, err := call(func(i *instance) api.Function { return i.authToken })
240+
token, err := call(ctx, func(i *instance) api.Function { return i.authToken })
210241
if !s.device || !errors.Is(err, guest.ErrAuthRefreshRequired) {
211242
return token, err
212243
}
@@ -216,7 +247,9 @@ func (s *Strategy) token(ctx context.Context) (string, error) {
216247
}
217248
err = withRefreshLock(ctx, path, func() error {
218249
var err error
219-
token, err = call(func(i *instance) api.Function { return i.authRefresh })
250+
// The one request that rotates a credential: it runs on after its
251+
// caller leaves (see refreshGrace).
252+
token, err = call(withRefresh(ctx), func(i *instance) api.Function { return i.authRefresh })
220253
return err
221254
})
222255
return token, err
@@ -232,6 +265,9 @@ func (s *Strategy) token(ctx context.Context) (string, error) {
232265
func (s *Strategy) MemoryLockError() error { return s.store.MemoryLockError() }
233266

234267
// Close drops the strategy's cached token and unregisters its provider.
268+
// It waits for a call in flight, including a device session's refresh that
269+
// a cancelled Token left running: that refresh finishes, for up to 10
270+
// seconds, so the rotated refresh token it brings is saved rather than lost.
235271
func (s *Strategy) Close() error {
236272
s.mu.Lock()
237273
defer s.mu.Unlock()

0 commit comments

Comments
 (0)