The server notices its own token has run out
Signing in worked and everything after it did not: the login screen took the password, PocketBase handed back the record and the token, and then /api/me answered "The requested resource wasn't found." The three symptoms underneath looked unrelated — a 404 on the profile, an organization whose name came back empty, a user list with nobody in it — and they are one thing. The API Server's superuser token had expired. PocketBase does not say so. A record call carrying a token it no longer accepts is not refused; the header is ignored and the request is served as a guest, and the collection rules answer in the transport's place. The organizations collection is superuser-only, so it 403s. A user record is guarded by a view rule, so it 404s — hidden rather than denied. The users list is rule-filtered, so it comes back 200 with an empty array. Not one of those is a 401, and a 401 was the only thing that made this client sign in again. So the token was acquired once at startup and then kept for the life of the container, and the retry meant to renew it could never fire. Uptime longer than the token's lifetime was all it took. Nothing had to change for it to break, which is why it broke on a stack nobody had touched. The client now reads the exp claim PocketBase stamps into the token and renews before spending it, a minute early so a call cannot land just after it lapses. A token with no readable expiry is still taken at face value, and the 401 retry stays where it is as the backstop for the case this does not cover. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fe1e314df9
commit
d4b9d0870e
2 files changed
+174
-6
No files matched your search
@@ -2,11 +2,13 @@ package pb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PocketBase hides a record it will not let you read behind a 404 rather than a
|
||||
@@ -31,9 +33,14 @@ type fakePB struct {
|
||||
email string
|
||||
password string
|
||||
|
||||
// When set, tokens are minted as JWTs carrying this lifetime in their exp
|
||||
// claim, the way PocketBase issues them. Zero keeps the opaque test token.
|
||||
jwtTTL time.Duration
|
||||
|
||||
authCalls int
|
||||
lastIdentity string
|
||||
guestAttempts int // record reads that arrived without the superuser token
|
||||
issued string // the token most recently handed out
|
||||
guestAttempts int // record reads that arrived without the superuser token
|
||||
}
|
||||
|
||||
func (f *fakePB) handler() http.Handler {
|
||||
@@ -51,11 +58,21 @@ func (f *fakePB) handler() http.Handler {
|
||||
writeTestJSON(w, 400, map[string]any{"message": "Failed to authenticate."})
|
||||
return
|
||||
}
|
||||
writeTestJSON(w, 200, map[string]any{"token": fakeSvcToken})
|
||||
token := fakeSvcToken
|
||||
if f.jwtTTL > 0 {
|
||||
token = testJWT(time.Now().Add(f.jwtTTL))
|
||||
}
|
||||
f.mu.Lock()
|
||||
f.issued = token
|
||||
f.mu.Unlock()
|
||||
writeTestJSON(w, 200, map[string]any{"token": token})
|
||||
})
|
||||
|
||||
mux.HandleFunc("GET /api/collections/users/records/{id}", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != fakeSvcToken {
|
||||
f.mu.Lock()
|
||||
live := f.issued
|
||||
f.mu.Unlock()
|
||||
if got := r.Header.Get("Authorization"); got == "" || got != live {
|
||||
f.mu.Lock()
|
||||
f.guestAttempts++
|
||||
f.mu.Unlock()
|
||||
@@ -190,3 +207,101 @@ func TestNoServiceAccountSkipsAuthentication(t *testing.T) {
|
||||
t.Errorf("attempted %d sign-in(s) with no credentials, want 0", auth)
|
||||
}
|
||||
}
|
||||
|
||||
// testJWT builds a token shaped like PocketBase's: three base64url segments,
|
||||
// the middle one carrying the exp claim. Only that claim is ever read, so the
|
||||
// header and signature are filler.
|
||||
func testJWT(exp time.Time) string {
|
||||
enc := func(v any) string {
|
||||
b, _ := json.Marshal(v)
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
return enc(map[string]string{"alg": "HS256", "typ": "JWT"}) + "." +
|
||||
enc(map[string]int64{"exp": exp.Unix()}) + ".sig"
|
||||
}
|
||||
|
||||
func TestJWTExpiry(t *testing.T) {
|
||||
want := time.Now().Add(time.Hour).Truncate(time.Second)
|
||||
if got := jwtExpiry(testJWT(want)); !got.Equal(want) {
|
||||
t.Errorf("jwtExpiry = %v, want %v", got, want)
|
||||
}
|
||||
// An opaque (non-JWT) token has no readable expiry, and must not be mistaken
|
||||
// for one that expired at the zero time — tokenLive takes it at face value.
|
||||
for _, tok := range []string{"", "opaque", "a.b", "a.!!.c", "a." + base64.RawURLEncoding.EncodeToString([]byte("{}")) + ".c"} {
|
||||
if got := jwtExpiry(tok); !got.IsZero() {
|
||||
t.Errorf("jwtExpiry(%q) = %v, want zero", tok, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The failure this whole mechanism exists for: a superuser token that has run
|
||||
// out. PocketBase does not answer 401 for one — it ignores the header and serves
|
||||
// the request as a guest, so the 401 retry never fires and the client would go
|
||||
// on presenting a dead token forever. The expiry check has to catch it first.
|
||||
func TestExpiredTokenIsRenewedBeforeUse(t *testing.T) {
|
||||
f := &fakePB{email: "admin@test.local", password: "pw", jwtTTL: time.Hour}
|
||||
c := New(newFakePB(t, f), f.email, f.password)
|
||||
|
||||
if err := c.GetOne(context.Background(), "users", fakeRecordID, new(struct{})); err != nil {
|
||||
t.Fatalf("GetOne: %v", err)
|
||||
}
|
||||
|
||||
// Age the cached token past its expiry, as an uptime longer than the token's
|
||||
// lifetime would.
|
||||
c.mu.Lock()
|
||||
c.tokenExp = time.Now().Add(-time.Minute)
|
||||
c.mu.Unlock()
|
||||
|
||||
var rec struct{ ID string }
|
||||
if err := c.GetOne(context.Background(), "users", fakeRecordID, &rec); err != nil {
|
||||
t.Fatalf("GetOne with an expired token: %v", err)
|
||||
}
|
||||
if rec.ID != fakeRecordID {
|
||||
t.Fatalf("record id = %q, want %q", rec.ID, fakeRecordID)
|
||||
}
|
||||
|
||||
auth, guest := f.counts()
|
||||
if guest != 0 {
|
||||
t.Errorf("%d record read(s) went out on a dead token, want 0", guest)
|
||||
}
|
||||
if auth != 2 {
|
||||
t.Errorf("authenticated %d time(s), want 2 (startup + renewal)", auth)
|
||||
}
|
||||
}
|
||||
|
||||
// A token close enough to its expiry that it could lapse mid-flight is renewed
|
||||
// rather than spent, so a call cannot land just after the token dies.
|
||||
func TestTokenNearingExpiryIsRenewed(t *testing.T) {
|
||||
f := &fakePB{email: "admin@test.local", password: "pw", jwtTTL: time.Hour}
|
||||
c := New(newFakePB(t, f), f.email, f.password)
|
||||
|
||||
if err := c.GetOne(context.Background(), "users", fakeRecordID, new(struct{})); err != nil {
|
||||
t.Fatalf("GetOne: %v", err)
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.tokenExp = time.Now().Add(tokenSkew / 2)
|
||||
c.mu.Unlock()
|
||||
|
||||
if err := c.GetOne(context.Background(), "users", fakeRecordID, new(struct{})); err != nil {
|
||||
t.Fatalf("GetOne near expiry: %v", err)
|
||||
}
|
||||
if auth, _ := f.counts(); auth != 2 {
|
||||
t.Errorf("authenticated %d time(s), want 2 (startup + renewal)", auth)
|
||||
}
|
||||
}
|
||||
|
||||
// A token with a real lifetime still ahead of it is reused — the expiry check
|
||||
// must not turn every call into a fresh sign-in.
|
||||
func TestLiveJWTIsReused(t *testing.T) {
|
||||
f := &fakePB{email: "admin@test.local", password: "pw", jwtTTL: time.Hour}
|
||||
c := New(newFakePB(t, f), f.email, f.password)
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
if err := c.GetOne(context.Background(), "users", fakeRecordID, new(struct{})); err != nil {
|
||||
t.Fatalf("GetOne #%d: %v", i, err)
|
||||
}
|
||||
}
|
||||
if auth, _ := f.counts(); auth != 1 {
|
||||
t.Errorf("authenticated %d time(s), want 1", auth)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user