Export the reviewed application-neutral package set through the exact public allowlist. Development history and private application evidence remain outside this canonical source root. Developed with material AI assistance under maintainer review. Signed-off-by: Cole Speelman <crspeelman@gmail.com>
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
// Package authhttp connects auth sessions to secure browser cookies and
|
||||
// request context without owning login pages or application routes.
|
||||
package authhttp
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gamertan.com/web/auth"
|
||||
"gamertan.com/web/websec"
|
||||
)
|
||||
|
||||
type CookieConfig struct {
|
||||
Name string
|
||||
Lifetime time.Duration
|
||||
SameSite http.SameSite
|
||||
}
|
||||
|
||||
func (config CookieConfig) Validate() error {
|
||||
if !strings.HasPrefix(config.Name, "__Host-") || len(config.Name) > 128 || strings.ContainsAny(config.Name, "\x00\r\n\t ;,") {
|
||||
return errors.New("authhttp: cookie name must use the __Host- prefix")
|
||||
}
|
||||
if config.Lifetime < 5*time.Minute || config.Lifetime > 30*24*time.Hour {
|
||||
return errors.New("authhttp: invalid cookie lifetime")
|
||||
}
|
||||
if config.SameSite == 0 {
|
||||
config.SameSite = http.SameSiteLaxMode
|
||||
}
|
||||
if config.SameSite != http.SameSiteLaxMode && config.SameSite != http.SameSiteStrictMode {
|
||||
return errors.New("authhttp: SameSite must be Lax or Strict")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func SetSession(response http.ResponseWriter, config CookieConfig, token string, now time.Time) error {
|
||||
if err := config.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if token == "" || len(token) > 128 {
|
||||
return errors.New("authhttp: invalid session token")
|
||||
}
|
||||
sameSite := config.SameSite
|
||||
if sameSite == 0 {
|
||||
sameSite = http.SameSiteLaxMode
|
||||
}
|
||||
http.SetCookie(response, &http.Cookie{Name: config.Name, Value: token, Path: "/", Secure: true, HttpOnly: true, SameSite: sameSite, Expires: now.UTC().Add(config.Lifetime), MaxAge: int(config.Lifetime.Seconds())})
|
||||
return nil
|
||||
}
|
||||
|
||||
func ClearSession(response http.ResponseWriter, config CookieConfig) error {
|
||||
if err := config.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
sameSite := config.SameSite
|
||||
if sameSite == 0 {
|
||||
sameSite = http.SameSiteLaxMode
|
||||
}
|
||||
http.SetCookie(response, &http.Cookie{Name: config.Name, Value: "", Path: "/", Secure: true, HttpOnly: true, SameSite: sameSite, Expires: time.Unix(1, 0), MaxAge: -1})
|
||||
return nil
|
||||
}
|
||||
|
||||
func SessionToken(request *http.Request, config CookieConfig) (string, bool) {
|
||||
cookie, err := request.Cookie(config.Name)
|
||||
if err != nil || cookie.Value == "" || len(cookie.Value) > 128 {
|
||||
return "", false
|
||||
}
|
||||
return cookie.Value, true
|
||||
}
|
||||
|
||||
func Optional(service *auth.Service, config CookieConfig) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
if token, ok := SessionToken(request, config); ok {
|
||||
if principal, err := service.Session(request.Context(), token); err == nil {
|
||||
request = request.WithContext(auth.WithPrincipal(request.Context(), principal))
|
||||
} else if !errors.Is(err, auth.ErrSessionNotFound) && !errors.Is(err, auth.ErrInactiveUser) {
|
||||
response.Header().Set("Cache-Control", "no-store")
|
||||
http.Error(response, "authentication unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
}
|
||||
next.ServeHTTP(response, request)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Require(permission string, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
principal, ok := auth.PrincipalFromContext(request.Context())
|
||||
if !ok {
|
||||
response.Header().Set("Cache-Control", "no-store")
|
||||
http.Error(response, "authentication required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if permission != "" && !principal.Has(permission) {
|
||||
response.Header().Set("Cache-Control", "no-store")
|
||||
http.Error(response, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(response, request)
|
||||
})
|
||||
}
|
||||
|
||||
func CSRFToken(sessionToken, purpose string) (string, error) {
|
||||
return websec.CSRFToken([]byte(sessionToken), purpose)
|
||||
}
|
||||
func VerifyCSRF(sessionToken, purpose, candidate string) bool {
|
||||
return websec.VerifyCSRF([]byte(sessionToken), purpose, candidate)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// 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 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"))
|
||||
}
|
||||
}
|
||||
|
||||
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) 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 }
|
||||
Reference in New Issue
Block a user