verify / verify (push) Successful in 3m14s
Publish the reviewed Web Foundations v0.1.0-preview.3 snapshot with cryptographic temporary credentials, explicit forced-rotation state, atomic password replacement and session revocation, additive SQLite migration, tests, and application-boundary documentation. Exported from reviewed private source b8fb4ff3cd012859f2d307dfb2a1cc783a38f6db after trusted CI run 257 and exact Go 1.26.6 verification. Material implementation assistance provided by OpenAI Codex; reviewed and verified through the maintainer workflow. Signed-off-by: Cole Speelman <crspeelman@gmail.com>
131 lines
5.0 KiB
Go
131 lines
5.0 KiB
Go
// SPDX-License-Identifier: MPL-2.0
|
|
|
|
package authhttp
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"gamertan.com/web/auth"
|
|
)
|
|
|
|
func TestSessionCookieContract(t *testing.T) {
|
|
config := CookieConfig{Name: "__Host-app_session", Lifetime: time.Hour, SameSite: http.SameSiteStrictMode}
|
|
response := httptest.NewRecorder()
|
|
if err := SetSession(response, config, strings.Repeat("x", 43), time.Unix(100, 0)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cookies := response.Result().Cookies()
|
|
if len(cookies) != 1 {
|
|
t.Fatalf("cookies=%d", len(cookies))
|
|
}
|
|
cookie := cookies[0]
|
|
if cookie.Path != "/" || !cookie.Secure || !cookie.HttpOnly || cookie.Domain != "" || cookie.SameSite != http.SameSiteStrictMode {
|
|
t.Fatalf("cookie=%+v", cookie)
|
|
}
|
|
}
|
|
|
|
func TestCookieRequiresHostPrefix(t *testing.T) {
|
|
if err := (CookieConfig{Name: "session", Lifetime: time.Hour}).Validate(); err == nil {
|
|
t.Fatal("weak cookie name accepted")
|
|
}
|
|
}
|
|
|
|
func TestSessionCookieRejectsShortToken(t *testing.T) {
|
|
config := CookieConfig{Name: "__Host-app_session", Lifetime: time.Hour}
|
|
if err := SetSession(httptest.NewRecorder(), config, "predictable", time.Unix(100, 0)); err == nil {
|
|
t.Fatal("short session token accepted")
|
|
}
|
|
}
|
|
|
|
func TestCSRFUsesSessionAndPurpose(t *testing.T) {
|
|
token := strings.Repeat("s", 43)
|
|
csrf, err := CSRFToken(token, "profile:update")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !VerifyCSRF(token, "profile:update", csrf) || VerifyCSRF(token, "profile:delete", csrf) {
|
|
t.Fatal("csrf binding failed")
|
|
}
|
|
}
|
|
|
|
func TestOptionalFailsClosedWhenSessionStorageIsUnavailable(t *testing.T) {
|
|
service, err := auth.New(authHTTPRepository{err: errors.New("storage offline")}, auth.Options{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
config := CookieConfig{Name: "__Host-app_session", Lifetime: time.Hour}
|
|
handler := Optional(service, config)(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
|
t.Fatal("handler ran while authentication state was unknown")
|
|
}))
|
|
request := httptest.NewRequest(http.MethodGet, "https://example.test/", nil)
|
|
request.AddCookie(&http.Cookie{Name: config.Name, Value: strings.Repeat("x", 43)})
|
|
response := httptest.NewRecorder()
|
|
handler.ServeHTTP(response, request)
|
|
if response.Code != http.StatusServiceUnavailable || response.Header().Get("Cache-Control") != "no-store" {
|
|
t.Fatalf("status=%d cache=%q", response.Code, response.Header().Get("Cache-Control"))
|
|
}
|
|
}
|
|
|
|
func TestOptionalFailsClosedWhenConfigurationIsInvalid(t *testing.T) {
|
|
for _, test := range []struct {
|
|
name string
|
|
service *auth.Service
|
|
config CookieConfig
|
|
}{
|
|
{name: "nil service", config: CookieConfig{Name: "__Host-app_session", Lifetime: time.Hour}},
|
|
{name: "invalid cookie", service: mustAuthService(t), config: CookieConfig{Name: "session", Lifetime: time.Hour}},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
handler := Optional(test.service, test.config)(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
|
t.Fatal("handler ran with invalid authentication configuration")
|
|
}))
|
|
response := httptest.NewRecorder()
|
|
handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "https://example.test/", nil))
|
|
if response.Code != http.StatusServiceUnavailable || response.Header().Get("Cache-Control") != "no-store" {
|
|
t.Fatalf("status=%d cache=%q", response.Code, response.Header().Get("Cache-Control"))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func mustAuthService(t *testing.T) *auth.Service {
|
|
t.Helper()
|
|
service, err := auth.New(authHTTPRepository{}, auth.Options{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return service
|
|
}
|
|
|
|
type authHTTPRepository struct{ err error }
|
|
|
|
func (authHTTPRepository) CreateUser(context.Context, auth.User, string) error { return nil }
|
|
func (authHTTPRepository) CredentialByIdentifier(context.Context, string) (auth.User, string, error) {
|
|
return auth.User{}, "", auth.ErrUserNotFound
|
|
}
|
|
func (authHTTPRepository) CredentialByUserID(context.Context, string) (auth.User, string, error) {
|
|
return auth.User{}, "", auth.ErrUserNotFound
|
|
}
|
|
func (authHTTPRepository) ReplacePasswordAndRevokeSessions(context.Context, string, string, string, time.Time) error {
|
|
return nil
|
|
}
|
|
func (authHTTPRepository) UpdateLastLogin(context.Context, string, time.Time) error { return nil }
|
|
func (authHTTPRepository) CreateSession(context.Context, auth.Session) error { return nil }
|
|
func (repository authHTTPRepository) PrincipalBySession(context.Context, [32]byte, time.Time) (auth.Principal, auth.Session, error) {
|
|
return auth.Principal{}, auth.Session{}, repository.err
|
|
}
|
|
func (authHTTPRepository) TouchSession(context.Context, [32]byte, time.Time) error { return nil }
|
|
func (authHTTPRepository) DeleteSession(context.Context, [32]byte) error { return nil }
|
|
func (authHTTPRepository) RevokeUserSessions(context.Context, string) error { return nil }
|
|
func (authHTTPRepository) SeedPolicy(context.Context, auth.PolicySeed) error { return nil }
|
|
func (authHTTPRepository) GrantRole(context.Context, string, string, time.Time) error {
|
|
return nil
|
|
}
|
|
func (authHTTPRepository) AppendAudit(context.Context, auth.AuditEvent) error { return nil }
|