package auth import ( "context" "net/http" "net/http/httptest" "testing" "time" ) type fakeStore struct { session Session err error digest []byte touched int } func (f *fakeStore) Lookup(_ context.Context, digest []byte, _ time.Time) (Session, error) { f.digest = digest return f.session, f.err } func (f *fakeStore) Touch(_ context.Context, _ []byte, _ time.Time, _ time.Duration) error { f.touched++ return nil } func newAuth(store SessionStore, now time.Time) (*Authenticator, *int) { denied := 0 return &Authenticator{ Sessions: store, IdleTTL: time.Hour, Now: func() time.Time { return now }, Deny: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { denied++ w.WriteHeader(http.StatusUnauthorized) }), }, &denied } func TestBothPresentationsYieldAPrincipal(t *testing.T) { now := time.Now() token := NewToken() for name, arm := range map[string]struct { set func(*http.Request) via Presentation }{ "cookie": {func(r *http.Request) { r.AddCookie(&http.Cookie{Name: CookieName, Value: token}) }, ViaCookie}, "bearer": {func(r *http.Request) { r.Header.Set("Authorization", "Bearer "+token) }, ViaBearer}, } { t.Run(name, func(t *testing.T) { store := &fakeStore{session: Session{ UserID: "u1", IdleExpiresAt: now.Add(time.Hour), AbsoluteExpiresAt: now.Add(24 * time.Hour), }} a, denied := newAuth(store, now) var seen Principal h := a.Require(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { seen, _ = FromContext(r.Context()) })) r := httptest.NewRequest(http.MethodGet, "/v0/books", nil) arm.set(r) h.ServeHTTP(httptest.NewRecorder(), r) if *denied != 0 { t.Fatalf("denied a live session") } if seen.UserID != "u1" || seen.Via != arm.via { t.Fatalf("principal = %+v", seen) } // What reaches the store is the digest, never the token itself. if string(store.digest) == token { t.Fatal("plaintext token reached the store") } }) } } func TestNoOrBrokenCredentialIsDenied(t *testing.T) { now := time.Now() for name, set := range map[string]func(*http.Request){ "nothing": func(*http.Request) {}, "empty cookie": func(r *http.Request) { r.AddCookie(&http.Cookie{Name: CookieName, Value: ""}) }, "wrong scheme": func(r *http.Request) { r.Header.Set("Authorization", "Basic abc") }, "bearer empty": func(r *http.Request) { r.Header.Set("Authorization", "Bearer ") }, "other cookie": func(r *http.Request) { r.AddCookie(&http.Cookie{Name: "tm_session", Value: "x"}) }, "store failure": func(r *http.Request) { r.Header.Set("Authorization", "Bearer t") }, } { t.Run(name, func(t *testing.T) { store := &fakeStore{err: ErrNoSession} a, denied := newAuth(store, now) reached := false h := a.Require(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { reached = true })) r := httptest.NewRequest(http.MethodGet, "/v0/books", nil) set(r) h.ServeHTTP(httptest.NewRecorder(), r) if reached { t.Fatal("handler ran without a live session") } if *denied != 1 { t.Fatalf("deny count = %d", *denied) } }) } } // Found by running the binary without a database: a presented token used to reach a nil store and // panic into a 500. Nothing can be proven without a store, so it must deny like any other miss. func TestNoStoreDeniesInsteadOfPanicking(t *testing.T) { a, denied := newAuth(nil, time.Now()) h := a.Require(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Fatal("handler ran with no session store") })) r := httptest.NewRequest(http.MethodGet, "/v0/books", nil) r.Header.Set("Authorization", "Bearer t") h.ServeHTTP(httptest.NewRecorder(), r) if *denied != 1 { t.Fatalf("deny count = %d", *denied) } } // An Authorization header, in ANY shape, takes the cookie out of play. This is what makes the CSRF // exemption for Bearer requests safe (PD-50): a caller who forges the exemption with a nonsense // Bearer authenticates as nothing rather than borrowing the cookie's authority. // Mutation caught: falling through to the cookie when the header does not parse. func TestAnAuthorizationHeaderTakesTheCookieOutOfPlay(t *testing.T) { for name, tc := range map[string]struct { header string want string // "" means no credential at all }{ "a well-formed bearer wins": {"Bearer bearer-token", "bearer-token"}, "a nonsense bearer is not the cookie": {"Bearer garbage", "garbage"}, "another scheme is not the cookie": {"Basic dXNlcjpwdw==", ""}, "a bare word is not the cookie": {"x", ""}, "an empty bearer value is not the cookie": {"Bearer ", ""}, } { t.Run(name, func(t *testing.T) { r := httptest.NewRequest(http.MethodGet, "/v0/books", nil) r.AddCookie(&http.Cookie{Name: CookieName, Value: "cookie-token"}) r.Header.Set("Authorization", tc.header) token, via, ok := Present(r, CookieName) if token == "cookie-token" || via == ViaCookie { t.Fatalf("the cookie authenticated a request carrying %q: the CSRF exemption would hand it the cookie's authority", tc.header) } if tc.want == "" { if ok { t.Fatalf("present() = %q %q %v, want no credential", token, via, ok) } return } if !ok || token != tc.want || via != ViaBearer { t.Fatalf("present() = %q %q %v, want %q via bearer", token, via, ok, tc.want) } }) } } func TestIdleWindowSlidesOnlyInItsSecondHalf(t *testing.T) { now := time.Now() for name, tc := range map[string]struct { remaining time.Duration want int }{ "fresh": {50 * time.Minute, 0}, "stale": {10 * time.Minute, 1}, } { t.Run(name, func(t *testing.T) { store := &fakeStore{session: Session{ UserID: "u1", IdleExpiresAt: now.Add(tc.remaining), AbsoluteExpiresAt: now.Add(24 * time.Hour), }} a, _ := newAuth(store, now) h := a.Require(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) r := httptest.NewRequest(http.MethodGet, "/v0/books", nil) r.Header.Set("Authorization", "Bearer t") h.ServeHTTP(httptest.NewRecorder(), r) if store.touched != tc.want { t.Fatalf("touches = %d, want %d", store.touched, tc.want) } }) } } func TestTokensAreUniqueAndDigestIsStable(t *testing.T) { seen := make(map[string]bool, 64) for range 64 { tok := NewToken() if len(tok) < 40 { // 32 bytes base64url ≈ 43 chars t.Fatalf("token too short: %q", tok) } if seen[tok] { t.Fatal("token repeated") } seen[tok] = true d := Digest(tok) if len(d) != 32 { t.Fatalf("digest is %d bytes", len(d)) } if string(d) == tok { t.Fatal("the stored form is the token itself") } } } // The browser's clock has to slide with the session row's. Max-Age is written once, at login, and // nothing else re-issues the cookie — so without a refresh here the cookie dies a fixed idle-TTL // after sign-in however much the session is used, a daily user is signed out on schedule while the // row is still live, and the absolute ceiling never gets to be what ends a session. Found by review. // Mutation caught: dropping the SetSession call, or issuing it on the Bearer path. func TestSlidingTheIdleWindowRefreshesTheBrowsersCookie(t *testing.T) { now := time.Now() cookie := func(r *http.Request) { r.AddCookie(&http.Cookie{Name: CookieName, Value: "tok"}) } bearer := func(r *http.Request) { r.Header.Set("Authorization", "Bearer tok") } for name, tc := range map[string]struct { remaining time.Duration // of the idle window absolute time.Duration // of the absolute window present func(*http.Request) wantSet bool wantMaxAge int // 0 means "the full idle TTL" }{ "cookie in the second half is refreshed": {10 * time.Minute, 24 * time.Hour, cookie, true, 0}, "cookie still fresh is left alone": {50 * time.Minute, 24 * time.Hour, cookie, false, 0}, "a bearer holder keeps its own token": {10 * time.Minute, 24 * time.Hour, bearer, false, 0}, // The cap. Without it the refreshed cookie outlives the session it names, and every request // after the ceiling is a 401 instead of a clean signed-out state. "the refresh never outlives the absolute window": {10 * time.Minute, 20 * time.Minute, cookie, true, 20 * 60}, } { t.Run(name, func(t *testing.T) { store := &fakeStore{session: Session{ UserID: "u1", IdleExpiresAt: now.Add(tc.remaining), AbsoluteExpiresAt: now.Add(tc.absolute), }} a, _ := newAuth(store, now) w := httptest.NewRecorder() r := httptest.NewRequest(http.MethodGet, "/v0/books", nil) tc.present(r) a.Require(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})).ServeHTTP(w, r) var got *http.Cookie for _, c := range w.Result().Cookies() { if c.Name == CookieName { got = c } } if !tc.wantSet { if got != nil { t.Fatalf("an unnecessary Set-Cookie was written: %+v", got) } return } if got == nil { t.Fatal("the idle window slid on the server and the cookie was not re-issued: the browser still expires at its login-time Max-Age") } wantMaxAge := tc.wantMaxAge if wantMaxAge == 0 { wantMaxAge = int(a.IdleTTL.Seconds()) } if got.MaxAge != wantMaxAge { t.Fatalf("refreshed cookie Max-Age = %d, want %d: a cookie that outlives its session turns the next request into a 401 instead of a signed-out state", got.MaxAge, wantMaxAge) } if got.Value != "tok" { t.Fatalf("the refresh changed the token to %q: rotation is a login-boundary act, not a slide", got.Value) } }) } } // The slide must stop once it can no longer move anything. pgstore.Touch clamps the new idle // deadline with least(now+IdleTTL, absolute_expires_at), so a session inside the last IdleTTL of its // absolute window has an idle deadline pinned to the ceiling — and "less than half a window left" // then stays true for every subsequent request. Without the guard that is an UPDATE on the session // row plus a Set-Cookie on EVERY authenticated call, on the hot path, for the last stretch of every // long-lived session. Found by review. Mutation caught: dropping the Before(AbsoluteExpiresAt) test. func TestTheSlideStopsOnceItCannotMoveTheDeadline(t *testing.T) { now := time.Now() // The shape Touch leaves behind: idle pinned to the absolute ceiling, well inside IdleTTL/2. store := &fakeStore{session: Session{ UserID: "u1", IdleExpiresAt: now.Add(5 * time.Minute), AbsoluteExpiresAt: now.Add(5 * time.Minute), }} a, _ := newAuth(store, now) h := a.Require(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) for range 5 { r := httptest.NewRequest(http.MethodGet, "/v0/books", nil) r.AddCookie(&http.Cookie{Name: CookieName, Value: "tok"}) h.ServeHTTP(httptest.NewRecorder(), r) } if store.touched != 0 { t.Fatalf("%d writes for 5 reads: the slide latched on a deadline it cannot move", store.touched) } // And a session that still has room continues to slide, so the guard did not disable sliding. store2 := &fakeStore{session: Session{ UserID: "u1", IdleExpiresAt: now.Add(5 * time.Minute), AbsoluteExpiresAt: now.Add(24 * time.Hour), }} b, _ := newAuth(store2, now) r := httptest.NewRequest(http.MethodGet, "/v0/books", nil) r.AddCookie(&http.Cookie{Name: CookieName, Value: "tok"}) b.Require(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})).ServeHTTP(httptest.NewRecorder(), r) if store2.touched != 1 { t.Fatalf("a session with room to slide was not slid: touches = %d", store2.touched) } }