textmachine/platform/internal/auth/middleware_test.go

183 lines
5.4 KiB
Go

package auth
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
)
type fakeStore struct {
session Session
err error
lookups int
digest []byte
touched int
touchTTL time.Duration
}
func (f *fakeStore) Lookup(_ context.Context, digest []byte, _ time.Time) (Session, error) {
f.lookups++
f.digest = digest
return f.session, f.err
}
func (f *fakeStore) Touch(_ context.Context, _ []byte, _ time.Time, ttl time.Duration) error {
f.touched++
f.touchTTL = ttl
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)
}
}
func TestBearerWinsOverCookie(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/v0/books", nil)
r.AddCookie(&http.Cookie{Name: CookieName, Value: "cookie-token"})
r.Header.Set("Authorization", "Bearer bearer-token")
token, via, ok := present(r)
if !ok || token != "bearer-token" || via != ViaBearer {
t.Fatalf("present() = %q %q %v", token, via, ok)
}
}
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")
}
}
}