309 lines
11 KiB
Go
309 lines
11 KiB
Go
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)
|
|
}
|
|
}
|