Compare commits

..
Author SHA1 Message Date
dev_agent 5e78465d34 Feature 04 task 2: Household week and local-time utilities (#18)
CI / ci (push) Successful in 1m25s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-03 09:58:06 -04:00
dev_agent 6d5c14f778 Feature 04 task 1: secretbox: AES-256-GCM secrets at rest (#17)
CI / ci (push) Successful in 1m26s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-03 09:46:29 -04:00
28 changed files with 900 additions and 3246 deletions
+2 -15
View File
@@ -30,27 +30,14 @@ export MEALQUEST_PUBLIC_URL=http://localhost:5173
export MEALQUEST_DATA_DIR="$HOME/.mealquest-dev"
export MEALQUEST_SHUTDOWN_TIMEOUT=10s
export MEALQUEST_SECRET_KEY_FILE="$HOME/.mealquest-dev/secret_key"
# Authentik OAuth2/OpenID provider for MealQuest (redirect URI:
# $MEALQUEST_PUBLIC_URL/auth/callback). Save its client secret to the file.
export MEALQUEST_OIDC_ISSUER=https://auth.example.com/application/o/mealquest/
export MEALQUEST_OIDC_CLIENT_ID=mealquest
export MEALQUEST_OIDC_CLIENT_SECRET_FILE="$HOME/.mealquest-dev/oidc_client_secret"
export MEALQUEST_OIDC_ALLOWED_GROUPS=mealquest-admin
# Optional: sessions end after MEALQUEST_SESSION_IDLE unused (default 720h)
# and always MEALQUEST_SESSION_MAX after sign-in (default 4320h).
# export MEALQUEST_SESSION_IDLE=720h
# export MEALQUEST_SESSION_MAX=4320h
make dev
```
`make dev` runs the Go server on `:8080` and the Vite dev server on `:5173`
(which proxies `/api`, `/auth` and `/media` to `:8080`). Open
http://localhost:5173. Configuration is environment-only and fails fast:
every variable above is required except the commented-out session
lifetimes (when set they must be positive, with MAX at least IDLE), and
secrets are read only from files named by `*_FILE` variables. `MEALQUEST_PUBLIC_URL`, `MEALQUEST_OIDC_ISSUER` and the
endpoints the issuer advertises must be `https://`; plain `http://` is
accepted only for `localhost`, `127.0.0.1` and `[::1]`.
every variable above is required, and secrets are read only from files named
by `*_FILE` variables.
## Common commands
+106 -7
View File
@@ -3,15 +3,21 @@ package main
import (
"bytes"
"context"
"crypto/rand"
"encoding/base64"
"errors"
"io/fs"
"log/slog"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"testing/fstest"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
)
@@ -24,6 +30,37 @@ func useBootMigrations(t *testing.T, m fstest.MapFS) {
t.Cleanup(func() { bootMigrations = saved })
}
// loadConfig loads a configuration through config.Load whose secret key file
// holds keyFile. Tests that call it must not run in parallel.
func loadConfig(t *testing.T, keyFile string) config.Config {
t.Helper()
keyPath := filepath.Join(t.TempDir(), "secret_key")
if err := os.WriteFile(keyPath, []byte(keyFile), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv(config.EnvHTTPAddr, "127.0.0.1:0")
t.Setenv(config.EnvPublicURL, "http://localhost")
t.Setenv(config.EnvDataDir, t.TempDir())
t.Setenv(config.EnvShutdownTimeout, "1s")
t.Setenv(config.EnvSecretKeyFile, keyPath)
cfg, err := config.Load()
if err != nil {
t.Fatal(err)
}
return cfg
}
// validKeyFile returns secret key file contents as written by the README:
// base64 of 32 random bytes followed by a newline.
func validKeyFile(t *testing.T) string {
t.Helper()
key := make([]byte, secretbox.KeySize)
if _, err := rand.Read(key); err != nil {
t.Fatal(err)
}
return base64.StdEncoding.EncodeToString(key) + "\n"
}
var brokenMigrations = fstest.MapFS{
"0000_good.sql": {Data: []byte("CREATE TABLE good (id INTEGER PRIMARY KEY);")},
"0001_broken.sql": {Data: []byte("CREATE TABLE broken (id INTEGER PRIMARY KEY;")},
@@ -73,12 +110,7 @@ func TestRunMigrationFailureReturnsBeforeListening(t *testing.T) {
useBootMigrations(t, brokenMigrations)
var logs bytes.Buffer
cfg := config.Config{
HTTPAddr: "127.0.0.1:0",
PublicURL: &url.URL{Scheme: "http", Host: "localhost"},
DataDir: t.TempDir(),
ShutdownTimeout: time.Second,
}
cfg := loadConfig(t, validKeyFile(t))
errc := make(chan error, 1)
go func() { errc <- run(cfg, slog.New(slog.NewJSONHandler(&logs, nil))) }()
@@ -94,3 +126,70 @@ func TestRunMigrationFailureReturnsBeforeListening(t *testing.T) {
t.Errorf("run started listening despite the migration failure: %s", logs.String())
}
}
func TestOpenSecretBoxAcceptsReadmeKey(t *testing.T) {
cfg := loadConfig(t, validKeyFile(t))
box, err := openSecretBox(cfg.SecretKey)
if err != nil {
t.Fatal(err)
}
token, err := box.Encrypt([]byte("x"))
if err != nil {
t.Fatal(err)
}
if _, err := box.Decrypt(token); err != nil {
t.Fatal(err)
}
}
func TestRunBadSecretKeyStopsBoot(t *testing.T) {
short := base64.StdEncoding.EncodeToString(make([]byte, 31))
tests := []struct {
name string
keyFile string
want error
}{
{"not base64", "this is not a base64 key!\n", secretbox.ErrKeyEncoding},
{"wrong length", short + "\n", secretbox.ErrKeyLength},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := loadConfig(t, tt.keyFile)
var logs bytes.Buffer
errc := make(chan error, 1)
go func() { errc <- run(cfg, slog.New(slog.NewJSONHandler(&logs, nil))) }()
var err error
select {
case err = <-errc:
case <-time.After(10 * time.Second):
t.Fatal("run kept going with a bad secret key")
}
if !errors.Is(err, tt.want) {
t.Fatalf("run error = %v, want %v", err, tt.want)
}
if !strings.Contains(err.Error(), config.EnvSecretKeyFile) {
t.Errorf("error does not name %s: %v", config.EnvSecretKeyFile, err)
}
if key := strings.TrimSpace(tt.keyFile); strings.Contains(err.Error(), key) {
t.Errorf("error leaks the key: %v", err)
}
if strings.Contains(logs.String(), `"listening"`) {
t.Errorf("run started listening despite the bad key: %s", logs.String())
}
if _, statErr := os.Stat(filepath.Join(cfg.DataDir, store.FileName)); !errors.Is(statErr, fs.ErrNotExist) {
t.Errorf("run opened the database before rejecting the key (stat: %v)", statErr)
}
})
}
}
func TestLoadMissingSecretKeyFileStopsBoot(t *testing.T) {
loadConfig(t, validKeyFile(t)) // sets every other variable
t.Setenv(config.EnvSecretKeyFile, filepath.Join(t.TempDir(), "missing"))
_, err := config.Load()
var cfgErr *config.Error
if !errors.As(err, &cfgErr) || !strings.Contains(err.Error(), config.EnvSecretKeyFile) {
t.Fatalf("Load error = %v, want a config error naming %s", err, config.EnvSecretKeyFile)
}
}
+16 -18
View File
@@ -13,9 +13,9 @@ import (
"syscall"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/server"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
@@ -42,6 +42,10 @@ func run(cfg config.Config, logger *slog.Logger) error {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if _, err := openSecretBox(cfg.SecretKey); err != nil {
return err
}
db, err := openStore(ctx, cfg.DataDir)
if err != nil {
return err
@@ -52,23 +56,7 @@ func run(cfg config.Config, logger *slog.Logger) error {
}
}()
sessions, err := auth.NewSessionManager(db, cfg.SessionIdle, cfg.SessionMax)
if err != nil {
return err
}
authFlow, err := auth.New(ctx, auth.Config{
Issuer: cfg.OIDCIssuer.String(),
ClientID: cfg.OIDCClientID,
ClientSecret: cfg.OIDCClientSecret.Reveal(),
PublicURL: cfg.PublicURL,
SecretKey: []byte(cfg.SecretKey.Reveal()),
AllowedGroups: cfg.OIDCAllowedGroups,
}, sessions, logger)
if err != nil {
return err
}
router, err := server.NewRouter(logger, version, authFlow, sessions, frontend.Assets())
router, err := server.NewRouter(logger, version, frontend.Assets())
if err != nil {
return err
}
@@ -103,6 +91,16 @@ func run(cfg config.Config, logger *slog.Logger) error {
return nil
}
// openSecretBox builds the secret box from the configured key, naming the
// variable whose file holds a bad key. The key itself is never reported.
func openSecretBox(key config.Secret) (*secretbox.Box, error) {
box, err := secretbox.New(key.Reveal())
if err != nil {
return nil, fmt.Errorf("%s: %w", config.EnvSecretKeyFile, err)
}
return box, nil
}
// bootMigrations is the migration set applied at boot. Tests replace it to
// prove a failing migration stops the boot.
var bootMigrations fs.FS = migrations.FS
-3
View File
@@ -3,16 +3,13 @@ module code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest
go 1.25.0
require (
github.com/coreos/go-oidc/v3 v3.21.0
github.com/go-chi/chi/v5 v5.3.1
github.com/oklog/ulid/v2 v2.1.2
golang.org/x/oauth2 v0.36.0
modernc.org/sqlite v1.59.0
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
-6
View File
@@ -1,11 +1,7 @@
github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM=
github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -23,8 +19,6 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
-35
View File
@@ -1,35 +0,0 @@
package auth
import (
"context"
"slices"
)
// Actor is the signed-in household member a request acts for. Handlers
// record Actor.Subject as updated_by on edits.
type Actor struct {
Subject string
Email string
Name string
Groups []string
}
// Actor returns the household member s belongs to.
func (s Session) Actor() Actor {
return Actor{Subject: s.Subject, Email: s.Email, Name: s.Name, Groups: slices.Clone(s.Groups)}
}
type actorKey struct{}
// WithActor returns a copy of ctx carrying actor. The session middleware
// calls it for every authenticated request.
func WithActor(ctx context.Context, actor Actor) context.Context {
return context.WithValue(ctx, actorKey{}, actor)
}
// ActorFrom returns the actor WithActor stored in ctx. ok is false when ctx
// has none, which behind the session middleware means a wiring bug.
func ActorFrom(ctx context.Context) (actor Actor, ok bool) {
actor, ok = ctx.Value(actorKey{}).(Actor)
return actor, ok
}
-32
View File
@@ -1,32 +0,0 @@
package auth
import (
"context"
"slices"
"testing"
)
func TestActorContext(t *testing.T) {
t.Parallel()
if _, ok := ActorFrom(context.Background()); ok {
t.Error("ActorFrom found an actor in an empty context")
}
want := Actor{Subject: "user-123", Email: "cook@example.com", Name: "Home Cook", Groups: []string{"mealquest-admin"}}
got, ok := ActorFrom(WithActor(context.Background(), want))
if !ok || got.Subject != want.Subject || got.Email != want.Email || got.Name != want.Name || !slices.Equal(got.Groups, want.Groups) {
t.Errorf("ActorFrom = %+v (ok=%v), want %+v", got, ok, want)
}
}
func TestSessionActor(t *testing.T) {
t.Parallel()
s := Session{Subject: "user-123", Email: "cook@example.com", Name: "Home Cook", Groups: []string{"mealquest-admin"}, CSRFToken: "t"}
a := s.Actor()
if a.Subject != s.Subject || a.Email != s.Email || a.Name != s.Name || !slices.Equal(a.Groups, s.Groups) {
t.Errorf("Actor = %+v, want the session's identity", a)
}
a.Groups[0] = "changed"
if s.Groups[0] != "mealquest-admin" {
t.Error("Actor shares the session's groups slice")
}
}
-398
View File
@@ -1,398 +0,0 @@
// Package auth signs the household in with Authentik over OpenID Connect.
// The service is a confidential client: the authorization code, tokens and
// client secret stay on the server and never reach JavaScript.
package auth
import (
"context"
"crypto/rand"
"crypto/subtle"
"errors"
"fmt"
"log/slog"
"net/http"
"net/url"
"slices"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/go-chi/chi/v5"
"golang.org/x/oauth2"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secureurl"
)
// Routes owned by this package.
const (
LoginPath = "/auth/login"
CallbackPath = "/auth/callback"
LogoutPath = "/auth/logout"
)
// providerTimeout bounds every request to the identity provider.
const providerTimeout = 10 * time.Second
// scopes requested at login.
var scopes = []string{oidc.ScopeOpenID, "profile", "email", "groups", oidc.ScopeOfflineAccess}
// Claims are the verified ID token claims handed to the session layer.
type Claims struct {
Subject string `json:"sub"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
Name string `json:"name"`
PreferredUsername string `json:"preferred_username"`
Groups []string `json:"groups"`
}
// Sessions is the session layer. StartSession is called once per successful
// login with claims from a verified ID token; it must set the session cookie
// on w. EndSession is called on logout; it must end the session r carries,
// if any, and clear the cookie on w. A returned error fails the request with
// 500.
type Sessions interface {
StartSession(w http.ResponseWriter, r *http.Request, claims Claims) error
EndSession(w http.ResponseWriter, r *http.Request) error
}
// Config is what New needs to reach the identity provider.
type Config struct {
// Issuer is the exact issuer URL; discovery runs against it.
Issuer string
ClientID string
ClientSecret string
// PublicURL is the service's public URL; its origin prefixes CallbackPath
// to form the redirect URI.
PublicURL *url.URL
// SecretKey is the server key the login cookie's signing key derives from.
SecretKey []byte
// AllowedGroups are the household's groups; a sign-in is refused unless
// the account is in at least one of them.
AllowedGroups []string
}
// Auth serves the login and callback routes.
type Auth struct {
oauth oauth2.Config
verifier *oidc.IDTokenVerifier
provider *oidc.Provider
client *http.Client
cookie loginCookie
sessions Sessions
allowed []string
// endSessionURL is the provider's end_session_endpoint; logout ends
// there so the provider's own session ends too.
endSessionURL string
logger *slog.Logger
}
// New discovers the provider at cfg.Issuer and returns the login flow. It
// fails if discovery fails or the issuer the provider reports differs.
func New(ctx context.Context, cfg Config, sessions Sessions, logger *slog.Logger) (*Auth, error) {
switch {
case cfg.Issuer == "", cfg.ClientID == "", cfg.ClientSecret == "":
return nil, errors.New("auth: issuer, client ID and client secret are required")
case cfg.PublicURL == nil:
return nil, errors.New("auth: public URL is required")
case len(cfg.SecretKey) == 0:
return nil, errors.New("auth: secret key is required")
case sessions == nil:
return nil, errors.New("auth: session layer is required")
case len(cfg.AllowedGroups) == 0:
return nil, errors.New("auth: at least one allowed group is required")
}
if err := checkEndpoint("issuer", cfg.Issuer); err != nil {
return nil, err
}
client := &http.Client{Timeout: providerTimeout, CheckRedirect: checkRedirect}
provider, err := oidc.NewProvider(oidc.ClientContext(ctx, client), cfg.Issuer)
if err != nil {
return nil, fmt.Errorf("auth: OIDC discovery: %w", err)
}
endpoint := provider.Endpoint()
var discovered struct {
JWKSURI string `json:"jwks_uri"`
EndSessionURI string `json:"end_session_endpoint"`
}
if err := provider.Claims(&discovered); err != nil {
return nil, fmt.Errorf("auth: OIDC discovery: %w", err)
}
// The token endpoint receives the client secret and the key set decides
// which tokens verify, so every endpoint used must pass the same rule.
for _, e := range []struct{ name, raw string }{
{"authorization endpoint", endpoint.AuthURL},
{"token endpoint", endpoint.TokenURL},
{"JWKS endpoint", discovered.JWKSURI},
{"end session endpoint", discovered.EndSessionURI},
// Groups come from userinfo when the ID token carries none, and the
// access token is sent there.
{"userinfo endpoint", provider.UserInfoEndpoint()},
} {
if err := checkEndpoint(e.name, e.raw); err != nil {
return nil, err
}
}
// client_secret_basic, the OIDC default; no auto-detection retries.
endpoint.AuthStyle = oauth2.AuthStyleInHeader
return &Auth{
oauth: oauth2.Config{
ClientID: cfg.ClientID,
ClientSecret: cfg.ClientSecret,
Endpoint: endpoint,
RedirectURL: cfg.PublicURL.Scheme + "://" + cfg.PublicURL.Host + CallbackPath,
Scopes: scopes,
},
verifier: provider.Verifier(&oidc.Config{
ClientID: cfg.ClientID,
SupportedSigningAlgs: []string{oidc.RS256},
}),
provider: provider,
client: client,
allowed: slices.Clone(cfg.AllowedGroups),
cookie: newLoginCookie(cfg.SecretKey),
sessions: sessions,
endSessionURL: discovered.EndSessionURI,
logger: logger,
}, nil
}
// checkEndpoint applies secureurl.Check to the provider URL raw, naming the
// endpoint in the error.
func checkEndpoint(name, raw string) error {
u, err := url.Parse(raw)
if err != nil {
return fmt.Errorf("auth: OIDC %s %q is not a valid URL", name, raw)
}
if err := secureurl.Check(u); err != nil {
return fmt.Errorf("auth: OIDC %s %q: %w", name, raw, err)
}
return nil
}
// maxRedirects matches net/http's default redirect limit.
const maxRedirects = 10
// checkRedirect is the provider client's redirect policy. On a 307 or 308 to
// the same host Go resends the body and Authorization header, which carry the
// code and client secret, so every target must pass secureurl.Check and an
// https:// request never follows a redirect to plain http://, even loopback.
func checkRedirect(req *http.Request, via []*http.Request) error {
if len(via) >= maxRedirects {
return fmt.Errorf("stopped after %d redirects", maxRedirects)
}
if err := secureurl.Check(req.URL); err != nil {
return fmt.Errorf("redirect refused: %w", err)
}
if via[len(via)-1].URL.Scheme == "https" && req.URL.Scheme != "https" {
return errors.New("redirect refused: https:// to plain http:// downgrade")
}
return nil
}
// Mount registers GET LoginPath, GET CallbackPath and POST LogoutPath on r.
func (a *Auth) Mount(r chi.Router) {
r.Get(LoginPath, a.login)
r.Get(CallbackPath, a.callback)
r.Post(LogoutPath, a.logout)
}
// EndSessionURL is the provider's end_session_endpoint.
func (a *Auth) EndSessionURL() string { return a.endSessionURL }
// login starts the authorization code flow with PKCE (S256). The optional
// "return" query parameter must be a same-origin path; anything else fails
// the login with 400.
func (a *Auth) login(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
returnTo, err := returnPath(r.URL.Query().Get("return"))
if err != nil {
http.Error(w, "invalid return path", http.StatusBadRequest)
return
}
state := loginState{
State: rand.Text(),
Nonce: rand.Text(),
Verifier: oauth2.GenerateVerifier(),
Return: returnTo,
}
if err := a.cookie.set(w, state, time.Now()); err != nil {
a.logger.ErrorContext(r.Context(), "set login cookie", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, a.oauth.AuthCodeURL(state.State,
oidc.Nonce(state.Nonce),
oauth2.S256ChallengeOption(state.Verifier),
), http.StatusFound)
}
// callback completes the flow: it checks state against the login cookie,
// exchanges the code, verifies the ID token and nonce, then starts a session
// if the account is in an allowed group. Membership is checked on every
// login, so an account removed from the group is refused at its next one.
func (a *Auth) callback(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
ctx := r.Context()
// The login cookie is single-use.
a.cookie.clear(w)
state, err := a.cookie.read(r, time.Now())
if err != nil {
a.logger.WarnContext(ctx, "login callback without a valid login cookie", "error", err)
http.Error(w, "sign-in expired; start again", http.StatusBadRequest)
return
}
q := r.URL.Query()
if subtle.ConstantTimeCompare([]byte(q.Get("state")), []byte(state.State)) != 1 {
a.logger.WarnContext(ctx, "login callback state mismatch")
http.Error(w, "sign-in state mismatch; start again", http.StatusBadRequest)
return
}
if q.Get("error") != "" {
// error and error_description are provider-supplied text; only the
// fixed category is logged.
a.logger.WarnContext(ctx, "identity provider returned an authorization error")
http.Error(w, "sign-in was refused", http.StatusUnauthorized)
return
}
code := q.Get("code")
if code == "" {
http.Error(w, "missing authorization code", http.StatusBadRequest)
return
}
claims, err := a.verify(ctx, code, state)
if err != nil {
a.logger.WarnContext(ctx, "sign-in failed", "error", err)
http.Error(w, "sign-in failed", http.StatusUnauthorized)
return
}
if !a.member(claims.Groups) {
if claims.Groups == nil {
a.logger.WarnContext(ctx, "sign-in refused: no groups claim in the ID token or userinfo", "subject", claims.Subject)
} else {
a.logger.WarnContext(ctx, "sign-in refused: not in an allowed group", "subject", claims.Subject)
}
// A session this browser already holds ends too, so a refused
// sign-in never leaves it signed in.
if err := a.sessions.EndSession(w, r); err != nil {
a.logger.ErrorContext(ctx, "end session", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
a.refuse(w, r)
return
}
if err := a.sessions.StartSession(w, r, claims); err != nil {
a.logger.ErrorContext(ctx, "start session", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, state.Return, http.StatusSeeOther)
}
// logout ends the session, clears the cookie and sends the browser to the
// provider's end_session_endpoint to end the provider session as well.
func (a *Auth) logout(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
if err := a.sessions.EndSession(w, r); err != nil {
a.logger.ErrorContext(r.Context(), "end session", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, a.endSessionURL, http.StatusSeeOther)
}
// verify exchanges code for tokens and returns the verified ID token claims.
// The verifier enforces RS256, issuer, audience and expiry; nonce is checked
// here. When the ID token has no groups claim, Groups comes from userinfo;
// it stays nil when neither has one.
func (a *Auth) verify(ctx context.Context, code string, state loginState) (Claims, error) {
token, err := a.oauth.Exchange(context.WithValue(ctx, oauth2.HTTPClient, a.client), code,
oauth2.VerifierOption(state.Verifier))
if err != nil {
return Claims{}, exchangeError(err)
}
raw, ok := token.Extra("id_token").(string)
if !ok || raw == "" {
return Claims{}, errors.New("token response has no id_token")
}
idToken, err := a.verifier.Verify(ctx, raw)
if err != nil {
return Claims{}, fmt.Errorf("verify ID token: %w", err)
}
if subtle.ConstantTimeCompare([]byte(idToken.Nonce), []byte(state.Nonce)) != 1 {
return Claims{}, errors.New("ID token nonce mismatch")
}
var claims Claims
if err := idToken.Claims(&claims); err != nil {
return Claims{}, fmt.Errorf("decode ID token claims: %w", err)
}
if claims.Subject == "" {
return Claims{}, errors.New("ID token has no subject")
}
if claims.Groups == nil {
groups, err := a.userinfoGroups(ctx, token, claims.Subject)
if err != nil {
return Claims{}, err
}
claims.Groups = groups
}
return claims, nil
}
// userinfoGroups returns the groups claim from the userinfo endpoint, nil if
// it has none. The userinfo subject must match the ID token's subject.
func (a *Auth) userinfoGroups(ctx context.Context, token *oauth2.Token, subject string) ([]string, error) {
info, err := a.provider.UserInfo(oidc.ClientContext(ctx, a.client), oauth2.StaticTokenSource(token))
if err != nil {
return nil, userinfoError(err)
}
if info.Subject != subject {
return nil, errors.New("userinfo subject does not match the ID token")
}
var claims struct {
Groups []string `json:"groups"`
}
if err := info.Claims(&claims); err != nil {
return nil, fmt.Errorf("decode userinfo claims: %w", err)
}
return claims.Groups, nil
}
// userinfoError reduces a failed userinfo request to a fixed category, like
// exchangeError: go-oidc's error text can carry the response body.
func userinfoError(err error) error {
var transport *url.Error
if errors.As(err, &transport) {
return fmt.Errorf("userinfo: request failed: %s: %w", transport.Op, transport.Err)
}
return errors.New("userinfo: endpoint returned an unusable response")
}
// member reports whether groups contains an allowed group.
func (a *Auth) member(groups []string) bool {
return slices.ContainsFunc(groups, func(g string) bool { return slices.Contains(a.allowed, g) })
}
// exchangeError reduces a failed code exchange to a fixed category and safe
// metadata. The oauth2 error text can carry the token endpoint's response
// body or error_description, which may echo credentials, so it is dropped.
func exchangeError(err error) error {
var retrieve *oauth2.RetrieveError
var transport *url.Error
switch {
case errors.As(err, &retrieve):
return fmt.Errorf("exchange code: token endpoint rejected the request (HTTP %d)", retrieve.Response.StatusCode)
case errors.As(err, &transport):
// Request failures never carry the response. The URL is dropped: after
// a refused redirect it is the provider-supplied Location.
return fmt.Errorf("exchange code: token request failed: %s: %w", transport.Op, transport.Err)
default:
return errors.New("exchange code: token endpoint returned an unusable response")
}
}
-967
View File
@@ -1,967 +0,0 @@
package auth
import (
"bytes"
"context"
"crypto"
"crypto/hmac"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"io"
"log/slog"
"maps"
"math/big"
"net/http"
"net/http/httptest"
"net/url"
"slices"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/go-chi/chi/v5"
)
const (
testClientID = "mealquest"
testClientSecret = "client-s3cret"
testKID = "test-key"
)
var testPublicURL = &url.URL{Scheme: "https", Host: "meals.example.com"}
// testAllowedGroups are the household's groups in tests.
var testAllowedGroups = []string{"mealquest-admin", "mealquest-family"}
// fakeProvider is an OIDC provider serving discovery, JWKS and a token
// endpoint. Each issued code maps to the ID token the token endpoint returns.
type fakeProvider struct {
t *testing.T
srv *httptest.Server
key *rsa.PrivateKey
mu sync.Mutex
codes map[string]issuedCode
// discoveryOverrides replace fields of the discovery document.
discoveryOverrides map[string]any
// tokenReply, when set, is returned by the token endpoint instead of a
// token.
tokenReply *tokenReply
// userinfo is the userinfo endpoint's response; userinfoHits counts
// requests to it.
userinfo map[string]any
userinfoHits int
// userinfoReply, when set, is returned by the userinfo endpoint instead.
userinfoReply *tokenReply
}
type tokenReply struct {
status int
body string
}
type issuedCode struct {
challenge string
idToken string
}
func newFakeProvider(t *testing.T) *fakeProvider {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
p := &fakeProvider{t: t, key: key, codes: map[string]issuedCode{}}
mux := http.NewServeMux()
mux.HandleFunc("GET /.well-known/openid-configuration", p.discovery)
mux.HandleFunc("GET /jwks", p.jwks)
mux.HandleFunc("POST /token", p.token)
mux.HandleFunc("GET /userinfo", p.userinfoEndpoint)
p.srv = httptest.NewServer(mux)
t.Cleanup(p.srv.Close)
return p
}
func (p *fakeProvider) issuer() string { return p.srv.URL }
func (p *fakeProvider) discovery(w http.ResponseWriter, _ *http.Request) {
doc := map[string]any{
"issuer": p.issuer(),
"authorization_endpoint": p.issuer() + "/authorize",
"token_endpoint": p.issuer() + "/token",
"jwks_uri": p.issuer() + "/jwks",
"userinfo_endpoint": p.issuer() + "/userinfo",
"end_session_endpoint": p.issuer() + "/end-session/",
"response_types_supported": []string{"code"},
"subject_types_supported": []string{"public"},
"id_token_signing_alg_values_supported": []string{"RS256", "HS256"},
}
p.mu.Lock()
maps.Copy(doc, p.discoveryOverrides)
p.mu.Unlock()
writeJSON(w, doc)
}
func (p *fakeProvider) jwks(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, map[string]any{"keys": []map[string]string{{
"kty": "RSA",
"kid": testKID,
"use": "sig",
"alg": "RS256",
"n": b64(p.key.N.Bytes()),
"e": b64(big.NewInt(int64(p.key.E)).Bytes()),
}}})
}
// token checks client authentication, the code and the PKCE verifier before
// returning the code's ID token.
func (p *fakeProvider) token(w http.ResponseWriter, r *http.Request) {
p.mu.Lock()
reply := p.tokenReply
p.mu.Unlock()
if reply != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(reply.status)
io.WriteString(w, reply.body)
return
}
id, secret, ok := r.BasicAuth()
if !ok || id != testClientID || secret != testClientSecret {
http.Error(w, `{"error":"invalid_client"}`, http.StatusUnauthorized)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, `{"error":"invalid_request"}`, http.StatusBadRequest)
return
}
p.mu.Lock()
issued, ok := p.codes[r.PostForm.Get("code")]
delete(p.codes, r.PostForm.Get("code"))
p.mu.Unlock()
if r.PostForm.Get("grant_type") != "authorization_code" || !ok ||
challengeS256(r.PostForm.Get("code_verifier")) != issued.challenge {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
io.WriteString(w, `{"error":"invalid_grant"}`)
return
}
writeJSON(w, map[string]any{
"access_token": "access-token",
"token_type": "Bearer",
"expires_in": 3600,
"id_token": issued.idToken,
})
}
// userinfoEndpoint checks the access token and returns p.userinfo, which
// defaults to the test user without a groups claim.
func (p *fakeProvider) userinfoEndpoint(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer access-token" {
http.Error(w, `{"error":"invalid_token"}`, http.StatusUnauthorized)
return
}
p.mu.Lock()
p.userinfoHits++
info, reply := p.userinfo, p.userinfoReply
p.mu.Unlock()
if reply != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(reply.status)
io.WriteString(w, reply.body)
return
}
if info == nil {
info = map[string]any{"sub": "user-123", "email": "cook@example.com"}
}
writeJSON(w, info)
}
func (p *fakeProvider) setUserinfo(info map[string]any) {
p.mu.Lock()
defer p.mu.Unlock()
p.userinfo = info
}
func (p *fakeProvider) userinfoRequests() int {
p.mu.Lock()
defer p.mu.Unlock()
return p.userinfoHits
}
// authorize stands in for the user signing in at the provider: it records a
// code bound to the PKCE challenge and returns it.
func (p *fakeProvider) authorize(challenge, idToken string) string {
code := rand.Text()
p.mu.Lock()
p.codes[code] = issuedCode{challenge: challenge, idToken: idToken}
p.mu.Unlock()
return code
}
// claims returns valid ID token claims for nonce.
func (p *fakeProvider) claims(nonce string) map[string]any {
now := time.Now()
return map[string]any{
"iss": p.issuer(),
"sub": "user-123",
"aud": testClientID,
"iat": now.Unix(),
"exp": now.Add(5 * time.Minute).Unix(),
"nonce": nonce,
"email": "cook@example.com",
"email_verified": true,
"name": "Home Cook",
"preferred_username": "cook",
"groups": []string{"mealquest-admin"},
}
}
func (p *fakeProvider) signRS256(claims map[string]any) string {
return signRS256(p.t, p.key, claims)
}
func signRS256(t *testing.T, key *rsa.PrivateKey, claims map[string]any) string {
t.Helper()
signing := jwtSigningInput(t, "RS256", claims)
digest := sha256.Sum256([]byte(signing))
sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, digest[:])
if err != nil {
t.Fatal(err)
}
return signing + "." + b64(sig)
}
// signHS256 signs with the client secret, as in an algorithm-confusion attack.
func (p *fakeProvider) signHS256(claims map[string]any) string {
signing := jwtSigningInput(p.t, "HS256", claims)
mac := hmac.New(sha256.New, []byte(testClientSecret))
mac.Write([]byte(signing))
return signing + "." + b64(mac.Sum(nil))
}
func jwtSigningInput(t *testing.T, alg string, claims map[string]any) string {
t.Helper()
header, err := json.Marshal(map[string]string{"alg": alg, "typ": "JWT", "kid": testKID})
if err != nil {
t.Fatal(err)
}
body, err := json.Marshal(claims)
if err != nil {
t.Fatal(err)
}
return b64(header) + "." + b64(body)
}
func challengeS256(verifier string) string {
sum := sha256.Sum256([]byte(verifier))
return b64(sum[:])
}
func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
}
// memorySessions is an in-memory Sessions that records the claims it was
// handed and how many sessions were ended.
type memorySessions struct {
mu sync.Mutex
claims []Claims
ended int
err error
}
func (m *memorySessions) StartSession(w http.ResponseWriter, _ *http.Request, claims Claims) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.err != nil {
return m.err
}
m.claims = append(m.claims, claims)
http.SetCookie(w, &http.Cookie{Name: "test_session", Value: claims.Subject})
return nil
}
func (m *memorySessions) EndSession(http.ResponseWriter, *http.Request) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.err != nil {
return m.err
}
m.ended++
return nil
}
func (m *memorySessions) started() []Claims {
m.mu.Lock()
defer m.mu.Unlock()
return slices.Clone(m.claims)
}
type harness struct {
provider *fakeProvider
sessions *memorySessions
router http.Handler
logs *syncBuffer
}
// syncBuffer is a bytes.Buffer safe for concurrent log writes.
type syncBuffer struct {
mu sync.Mutex
buf bytes.Buffer
}
func (b *syncBuffer) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.Write(p)
}
func (b *syncBuffer) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.String()
}
func newHarness(t *testing.T) *harness {
t.Helper()
p := newFakeProvider(t)
sessions := &memorySessions{}
logs := &syncBuffer{}
a, err := New(context.Background(), Config{
Issuer: p.issuer(),
ClientID: testClientID,
ClientSecret: testClientSecret,
PublicURL: testPublicURL,
SecretKey: []byte("server-secret-key"),
AllowedGroups: testAllowedGroups,
}, sessions, slog.New(slog.NewTextHandler(logs, nil)))
if err != nil {
t.Fatal(err)
}
r := chi.NewRouter()
a.Mount(r)
return &harness{provider: p, sessions: sessions, router: r, logs: logs}
}
func (h *harness) get(target string, cookies ...*http.Cookie) *http.Response {
req := httptest.NewRequest(http.MethodGet, target, nil)
for _, c := range cookies {
req.AddCookie(c)
}
rec := httptest.NewRecorder()
h.router.ServeHTTP(rec, req)
return rec.Result()
}
// login is a started login: the authorize URL's query and the login cookie.
type login struct {
query url.Values
cookie *http.Cookie
}
func (h *harness) login(t *testing.T, target string) login {
t.Helper()
resp := h.get(target)
if resp.StatusCode != http.StatusFound {
t.Fatalf("GET %s status = %d, want 302", target, resp.StatusCode)
}
loc, err := url.Parse(resp.Header.Get("Location"))
if err != nil {
t.Fatal(err)
}
if got, want := loc.Scheme+"://"+loc.Host+loc.Path, h.provider.issuer()+"/authorize"; got != want {
t.Fatalf("redirect to %s, want %s", got, want)
}
var cookie *http.Cookie
for _, c := range resp.Cookies() {
if c.Name == loginCookieName {
cookie = c
}
}
if cookie == nil {
t.Fatalf("login set no %s cookie", loginCookieName)
}
return login{query: loc.Query(), cookie: cookie}
}
// callback completes l at the provider with idToken and calls the callback.
func (h *harness) callback(l login, state, idToken string) *http.Response {
code := h.provider.authorize(l.query.Get("code_challenge"), idToken)
q := url.Values{"code": {code}, "state": {state}}
return h.get(CallbackPath+"?"+q.Encode(), l.cookie)
}
func TestLoginRedirect(t *testing.T) {
t.Parallel()
h := newHarness(t)
l := h.login(t, LoginPath+"?return=/plan")
want := map[string]string{
"client_id": testClientID,
"response_type": "code",
"scope": "openid profile email groups offline_access",
"redirect_uri": "https://meals.example.com/auth/callback",
"code_challenge_method": "S256",
}
for k, v := range want {
if got := l.query.Get(k); got != v {
t.Errorf("authorize %s = %q, want %q", k, got, v)
}
}
for _, k := range []string{"state", "nonce", "code_challenge"} {
if l.query.Get(k) == "" {
t.Errorf("authorize URL has no %s", k)
}
}
c := l.cookie
if !c.HttpOnly || !c.Secure || c.SameSite != http.SameSiteLaxMode || c.Path != "/" {
t.Errorf("login cookie HttpOnly=%v Secure=%v SameSite=%v Path=%q, want HttpOnly, Secure, Lax, /",
c.HttpOnly, c.Secure, c.SameSite, c.Path)
}
if c.MaxAge <= 0 || c.MaxAge > 600 {
t.Errorf("login cookie MaxAge = %d, want 1-600 seconds", c.MaxAge)
}
}
func TestLoginStateIsFreshEachTime(t *testing.T) {
t.Parallel()
h := newHarness(t)
a, b := h.login(t, LoginPath), h.login(t, LoginPath)
for _, k := range []string{"state", "nonce", "code_challenge"} {
if a.query.Get(k) == b.query.Get(k) {
t.Errorf("%s repeated across logins", k)
}
}
}
func TestCallbackSuccess(t *testing.T) {
t.Parallel()
h := newHarness(t)
l := h.login(t, LoginPath+"?return="+url.QueryEscape("/plan?week=2026-W40"))
resp := h.callback(l, l.query.Get("state"), h.provider.signRS256(h.provider.claims(l.query.Get("nonce"))))
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("callback status = %d, want 303", resp.StatusCode)
}
if got := resp.Header.Get("Location"); got != "/plan?week=2026-W40" {
t.Errorf("callback redirect = %q, want the return path", got)
}
started := h.sessions.started()
want := Claims{
Subject: "user-123",
Email: "cook@example.com",
EmailVerified: true,
Name: "Home Cook",
PreferredUsername: "cook",
Groups: []string{"mealquest-admin"},
}
if len(started) != 1 || started[0].Subject != want.Subject || started[0].Email != want.Email ||
started[0].Name != want.Name || started[0].PreferredUsername != want.PreferredUsername ||
!started[0].EmailVerified || !slices.Equal(started[0].Groups, want.Groups) {
t.Errorf("session started with %+v, want [%+v]", started, want)
}
assertLoginCookieCleared(t, resp)
}
func TestCallbackDefaultReturnPath(t *testing.T) {
t.Parallel()
h := newHarness(t)
l := h.login(t, LoginPath)
resp := h.callback(l, l.query.Get("state"), h.provider.signRS256(h.provider.claims(l.query.Get("nonce"))))
if resp.StatusCode != http.StatusSeeOther || resp.Header.Get("Location") != "/" {
t.Errorf("callback = %d to %q, want 303 to /", resp.StatusCode, resp.Header.Get("Location"))
}
}
func TestCallbackRejected(t *testing.T) {
t.Parallel()
tests := []struct {
name string
// state returns the state sent to the callback.
state func(l login) string
// token returns the ID token the provider issues.
token func(p *fakeProvider, l login) string
wantStatus int
}{
{
name: "mismatched state",
state: func(login) string { return "attacker-state" },
wantStatus: http.StatusBadRequest,
},
{
name: "wrong nonce",
token: func(p *fakeProvider, _ login) string {
return p.signRS256(p.claims("other-nonce"))
},
wantStatus: http.StatusUnauthorized,
},
{
name: "missing nonce",
token: func(p *fakeProvider, _ login) string {
c := p.claims("")
delete(c, "nonce")
return p.signRS256(c)
},
wantStatus: http.StatusUnauthorized,
},
{
name: "HS256-signed token",
token: func(p *fakeProvider, l login) string {
return p.signHS256(p.claims(l.query.Get("nonce")))
},
wantStatus: http.StatusUnauthorized,
},
{
name: "expired token",
token: func(p *fakeProvider, l login) string {
c := p.claims(l.query.Get("nonce"))
c["iat"] = time.Now().Add(-2 * time.Hour).Unix()
c["exp"] = time.Now().Add(-time.Hour).Unix()
return p.signRS256(c)
},
wantStatus: http.StatusUnauthorized,
},
{
name: "wrong issuer",
token: func(p *fakeProvider, l login) string {
c := p.claims(l.query.Get("nonce"))
c["iss"] = "https://evil.example.com"
return p.signRS256(c)
},
wantStatus: http.StatusUnauthorized,
},
{
name: "wrong audience",
token: func(p *fakeProvider, l login) string {
c := p.claims(l.query.Get("nonce"))
c["aud"] = "another-client"
return p.signRS256(c)
},
wantStatus: http.StatusUnauthorized,
},
{
name: "signed by an unknown key",
token: func(p *fakeProvider, l login) string {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
p.t.Fatal(err)
}
return signRS256(p.t, key, p.claims(l.query.Get("nonce")))
},
wantStatus: http.StatusUnauthorized,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
h := newHarness(t)
l := h.login(t, LoginPath)
state := l.query.Get("state")
if tc.state != nil {
state = tc.state(l)
}
token := h.provider.signRS256(h.provider.claims(l.query.Get("nonce")))
if tc.token != nil {
token = tc.token(h.provider, l)
}
resp := h.callback(l, state, token)
if resp.StatusCode != tc.wantStatus {
t.Errorf("callback status = %d, want %d", resp.StatusCode, tc.wantStatus)
}
if n := len(h.sessions.started()); n != 0 {
t.Errorf("%d sessions started, want none", n)
}
assertLoginCookieCleared(t, resp)
})
}
}
func TestCallbackWithoutLoginCookie(t *testing.T) {
t.Parallel()
h := newHarness(t)
l := h.login(t, LoginPath)
l.cookie = &http.Cookie{Name: "unrelated", Value: "x"}
resp := h.callback(l, l.query.Get("state"), h.provider.signRS256(h.provider.claims(l.query.Get("nonce"))))
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("callback status = %d, want 400", resp.StatusCode)
}
if n := len(h.sessions.started()); n != 0 {
t.Errorf("%d sessions started, want none", n)
}
}
func TestCallbackTamperedLoginCookie(t *testing.T) {
t.Parallel()
h := newHarness(t)
l := h.login(t, LoginPath)
payload, sig, _ := strings.Cut(l.cookie.Value, ".")
data, err := base64.RawURLEncoding.DecodeString(payload)
if err != nil {
t.Fatal(err)
}
var s loginState
if err := json.Unmarshal(data, &s); err != nil {
t.Fatal(err)
}
s.Return = "/somewhere-else"
data, err = json.Marshal(s)
if err != nil {
t.Fatal(err)
}
l.cookie = &http.Cookie{Name: loginCookieName, Value: b64(data) + "." + sig}
resp := h.callback(l, l.query.Get("state"), h.provider.signRS256(h.provider.claims(l.query.Get("nonce"))))
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("callback status = %d, want 400", resp.StatusCode)
}
}
// The provider's error and error_description are untrusted text and never
// reach the logs; only the fixed category does.
func TestCallbackProviderError(t *testing.T) {
t.Parallel()
const (
errorMarker = "provider-error-marker"
descriptionMarker = "description-marker-" + testClientSecret
)
h := newHarness(t)
l := h.login(t, LoginPath)
q := url.Values{
"error": {errorMarker},
"error_description": {descriptionMarker},
"state": {l.query.Get("state")},
}
resp := h.get(CallbackPath+"?"+q.Encode(), l.cookie)
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("callback status = %d, want 401", resp.StatusCode)
}
assertLoginCookieCleared(t, resp)
if n := len(h.sessions.started()); n != 0 {
t.Errorf("started %d sessions, want 0", n)
}
logs := h.logs.String()
for _, marker := range []string{errorMarker, descriptionMarker} {
if strings.Contains(logs, marker) {
t.Errorf("logs contain provider text %q: %s", marker, logs)
}
}
if !strings.Contains(logs, "identity provider returned an authorization error") {
t.Errorf("logs = %q, want the authorization error category", logs)
}
}
func TestCallbackSessionError(t *testing.T) {
t.Parallel()
h := newHarness(t)
h.sessions.err = errors.New("database down")
l := h.login(t, LoginPath)
resp := h.callback(l, l.query.Get("state"), h.provider.signRS256(h.provider.claims(l.query.Get("nonce"))))
if resp.StatusCode != http.StatusInternalServerError {
t.Errorf("callback status = %d, want 500", resp.StatusCode)
}
}
// A failed code exchange is logged by category and status only: the token
// endpoint's body and error_description may echo credentials.
func TestCallbackExchangeFailureDoesNotLogProviderResponse(t *testing.T) {
t.Parallel()
const leaked = "leaked-" + testClientSecret
tests := []struct {
name string
reply tokenReply
wantLogs string
}{
{
name: "error response",
reply: tokenReply{http.StatusBadRequest, `{"error":"invalid_grant","error_description":"` + leaked + `"}`},
wantLogs: "HTTP 400",
},
{
name: "unparseable error response",
reply: tokenReply{http.StatusInternalServerError, leaked},
wantLogs: "HTTP 500",
},
{
name: "success without access token",
reply: tokenReply{http.StatusOK, `{"id_token":"` + leaked + `"}`},
wantLogs: "unusable response",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
h := newHarness(t)
h.provider.mu.Lock()
h.provider.tokenReply = &tc.reply
h.provider.mu.Unlock()
l := h.login(t, LoginPath)
resp := h.callback(l, l.query.Get("state"), "unused")
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("callback status = %d, want 401", resp.StatusCode)
}
logs := h.logs.String()
if strings.Contains(logs, leaked) {
t.Errorf("logs contain the provider response: %s", logs)
}
if !strings.Contains(logs, tc.wantLogs) {
t.Errorf("logs = %q, want %q", logs, tc.wantLogs)
}
})
}
}
func TestLoginCookieExpires(t *testing.T) {
t.Parallel()
c := newLoginCookie([]byte("server-secret-key"))
now := time.Now()
rec := httptest.NewRecorder()
if err := c.set(rec, loginState{State: "s", Nonce: "n", Verifier: "v", Return: "/"}, now); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, CallbackPath, nil)
req.AddCookie(rec.Result().Cookies()[0])
if _, err := c.read(req, now.Add(loginCookieTTL-time.Second)); err != nil {
t.Errorf("read before expiry: %v", err)
}
if _, err := c.read(req, now.Add(loginCookieTTL)); err == nil {
t.Error("read at expiry succeeded")
}
}
func TestLoginRejectsOffOriginReturn(t *testing.T) {
t.Parallel()
h := newHarness(t)
for _, ret := range []string{
"https://evil.example.com/",
"http://meals.example.com/plan",
"//evil.example.com/",
"/\\evil.example.com",
"\\\\evil.example.com",
"javascript:alert(1)",
"plan",
"/plan\r\nSet-Cookie: x=y",
} {
resp := h.get(LoginPath + "?return=" + url.QueryEscape(ret))
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("return=%q: status = %d, want 400", ret, resp.StatusCode)
}
if loc := resp.Header.Get("Location"); loc != "" {
t.Errorf("return=%q: redirected to %q", ret, loc)
}
if len(resp.Cookies()) != 0 {
t.Errorf("return=%q: set cookies %v", ret, resp.Cookies())
}
}
}
func TestNewFailsWhenDiscoveryFails(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.NotFoundHandler())
t.Cleanup(srv.Close)
_, err := New(context.Background(), Config{
Issuer: srv.URL,
ClientID: testClientID,
ClientSecret: testClientSecret,
PublicURL: testPublicURL,
SecretKey: []byte("k"),
AllowedGroups: testAllowedGroups,
}, &memorySessions{}, slog.New(slog.DiscardHandler))
if err == nil {
t.Fatal("New succeeded without a discovery document")
}
}
func TestNewFailsOnIssuerMismatch(t *testing.T) {
t.Parallel()
p := newFakeProvider(t)
// Discovery trims the trailing slash, so this fetches the provider's
// document, which reports the issuer without it.
_, err := New(context.Background(), Config{
Issuer: p.issuer() + "/",
ClientID: testClientID,
ClientSecret: testClientSecret,
PublicURL: testPublicURL,
SecretKey: []byte("k"),
AllowedGroups: testAllowedGroups,
}, &memorySessions{}, slog.New(slog.DiscardHandler))
if err == nil {
t.Fatal("New accepted a provider reporting a different issuer")
}
}
func assertLoginCookieCleared(t *testing.T, resp *http.Response) {
t.Helper()
for _, c := range resp.Cookies() {
if c.Name == loginCookieName && c.MaxAge < 0 {
return
}
}
t.Errorf("callback did not clear the %s cookie", loginCookieName)
}
func TestNewRejectsRemoteHTTPIssuer(t *testing.T) {
t.Parallel()
for _, issuer := range []string{"http://auth.example.com/application/o/mealquest/", "http://192.0.2.10"} {
_, err := New(context.Background(), Config{
Issuer: issuer,
ClientID: testClientID,
ClientSecret: testClientSecret,
PublicURL: testPublicURL,
SecretKey: []byte("k"),
AllowedGroups: testAllowedGroups,
}, &memorySessions{}, slog.New(slog.DiscardHandler))
if err == nil || !strings.Contains(err.Error(), "issuer") {
t.Errorf("New(issuer %q) error = %v, want issuer rejected", issuer, err)
}
}
}
func TestNewRejectsRemoteHTTPEndpoint(t *testing.T) {
t.Parallel()
tests := []struct {
field, name string
}{
{"authorization_endpoint", "authorization endpoint"},
{"token_endpoint", "token endpoint"},
{"jwks_uri", "JWKS endpoint"},
{"end_session_endpoint", "end session endpoint"},
{"userinfo_endpoint", "userinfo endpoint"},
}
for _, tc := range tests {
t.Run(tc.field, func(t *testing.T) {
t.Parallel()
p := newFakeProvider(t)
p.discoveryOverrides = map[string]any{tc.field: "http://auth.example.com/" + tc.field}
_, err := New(context.Background(), Config{
Issuer: p.issuer(),
ClientID: testClientID,
ClientSecret: testClientSecret,
PublicURL: testPublicURL,
SecretKey: []byte("k"),
AllowedGroups: testAllowedGroups,
}, &memorySessions{}, slog.New(slog.DiscardHandler))
if err == nil || !strings.Contains(err.Error(), tc.name) {
t.Errorf("New error = %v, want %s rejected", err, tc.name)
}
})
}
}
// A token endpoint on https:// that redirects the code exchange to plain
// http:// must not receive a follow-up request: Go would resend the client
// secret in the Authorization header. The plaintext target is loopback, so
// only the downgrade rule stops it.
func TestCallbackRefusesTokenRedirectToHTTP(t *testing.T) {
t.Parallel()
for _, status := range []int{http.StatusTemporaryRedirect, http.StatusPermanentRedirect} {
t.Run(http.StatusText(status), func(t *testing.T) {
t.Parallel()
var plaintextHits atomic.Int32
plaintext := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
plaintextHits.Add(1)
http.Error(w, "unreachable", http.StatusTeapot)
}))
t.Cleanup(plaintext.Close)
var tlsHits atomic.Int32
tlsToken := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tlsHits.Add(1)
http.Redirect(w, r, plaintext.URL+"/token", status)
}))
t.Cleanup(tlsToken.Close)
p := newFakeProvider(t)
p.discoveryOverrides = map[string]any{"token_endpoint": tlsToken.URL + "/token"}
sessions := &memorySessions{}
logs := &syncBuffer{}
a, err := New(context.Background(), Config{
Issuer: p.issuer(),
ClientID: testClientID,
ClientSecret: testClientSecret,
PublicURL: testPublicURL,
SecretKey: []byte("server-secret-key"),
AllowedGroups: testAllowedGroups,
}, sessions, slog.New(slog.NewTextHandler(logs, nil)))
if err != nil {
t.Fatal(err)
}
// Trust the test server's certificate; the redirect policy stays.
a.client.Transport = tlsToken.Client().Transport
r := chi.NewRouter()
a.Mount(r)
h := &harness{provider: p, sessions: sessions, router: r, logs: logs}
l := h.login(t, LoginPath)
resp := h.callback(l, l.query.Get("state"), "unused")
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("callback status = %d, want 401", resp.StatusCode)
}
if n := tlsHits.Load(); n != 1 {
t.Errorf("https token endpoint hit %d times, want 1", n)
}
if n := plaintextHits.Load(); n != 0 {
t.Errorf("plaintext endpoint hit %d times, want 0", n)
}
if n := len(sessions.started()); n != 0 {
t.Errorf("started %d sessions, want 0", n)
}
if got := logs.String(); !strings.Contains(got, "downgrade") {
t.Errorf("logs = %q, want the downgrade refusal", got)
}
})
}
}
func TestCheckRedirect(t *testing.T) {
t.Parallel()
request := func(raw string) *http.Request {
req, err := http.NewRequest(http.MethodPost, raw, nil)
if err != nil {
t.Fatal(err)
}
return req
}
tests := []struct {
from, to string
ok bool
}{
{"https://auth.example.com/token", "https://auth2.example.com/token", true},
{"http://127.0.0.1:9000/token", "http://localhost:9001/token", true},
{"http://127.0.0.1:9000/token", "https://auth.example.com/token", true},
{"https://auth.example.com/token", "http://auth.example.com/token", false},
{"https://127.0.0.1:9000/token", "http://127.0.0.1:9001/token", false},
{"http://127.0.0.1:9000/token", "http://auth.example.com/token", false},
{"https://auth.example.com/token", "ftp://auth.example.com/token", false},
}
for _, tc := range tests {
err := checkRedirect(request(tc.to), []*http.Request{request(tc.from)})
if (err == nil) != tc.ok {
t.Errorf("redirect %s -> %s: error = %v, want ok=%v", tc.from, tc.to, err, tc.ok)
}
}
via := make([]*http.Request, maxRedirects)
for i := range via {
via[i] = request("https://auth.example.com/token")
}
if err := checkRedirect(request("https://auth.example.com/token"), via); err == nil {
t.Errorf("redirect after %d hops: error = nil, want limit", maxRedirects)
}
}
-124
View File
@@ -1,124 +0,0 @@
package auth
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
)
const (
loginCookieName = "__Host-mealquest_login"
loginCookieTTL = 10 * time.Minute
// loginCookieKeyLabel separates the login cookie's signing key from other
// keys derived from the same server secret.
loginCookieKeyLabel = "mealquest auth login cookie v1"
)
// loginState travels from login to callback in the signed login cookie.
type loginState struct {
State string `json:"s"`
Nonce string `json:"n"`
Verifier string `json:"v"`
Return string `json:"r"`
Expires int64 `json:"e"` // Unix seconds
}
// loginCookie signs and verifies the login cookie with HMAC-SHA256.
type loginCookie struct {
key []byte
}
func newLoginCookie(secretKey []byte) loginCookie {
mac := hmac.New(sha256.New, secretKey)
mac.Write([]byte(loginCookieKeyLabel))
return loginCookie{key: mac.Sum(nil)}
}
func (c loginCookie) sign(payload string) string {
mac := hmac.New(sha256.New, c.key)
mac.Write([]byte(payload))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
func (c loginCookie) set(w http.ResponseWriter, s loginState, now time.Time) error {
s.Expires = now.Add(loginCookieTTL).Unix()
data, err := json.Marshal(s)
if err != nil {
return err
}
payload := base64.RawURLEncoding.EncodeToString(data)
http.SetCookie(w, &http.Cookie{
Name: loginCookieName,
Value: payload + "." + c.sign(payload),
Path: "/",
MaxAge: int(loginCookieTTL / time.Second),
Secure: true,
HttpOnly: true,
// Lax so the cookie rides the provider's top-level redirect back.
SameSite: http.SameSiteLaxMode,
})
return nil
}
func (c loginCookie) clear(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: loginCookieName,
Path: "/",
MaxAge: -1,
Secure: true,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
}
func (c loginCookie) read(r *http.Request, now time.Time) (loginState, error) {
cookie, err := r.Cookie(loginCookieName)
if err != nil {
return loginState{}, errors.New("login cookie missing")
}
payload, sig, ok := strings.Cut(cookie.Value, ".")
if !ok || !hmac.Equal([]byte(sig), []byte(c.sign(payload))) {
return loginState{}, errors.New("login cookie signature invalid")
}
data, err := base64.RawURLEncoding.DecodeString(payload)
if err != nil {
return loginState{}, fmt.Errorf("login cookie encoding: %w", err)
}
var s loginState
if err := json.Unmarshal(data, &s); err != nil {
return loginState{}, fmt.Errorf("login cookie payload: %w", err)
}
if now.Unix() >= s.Expires {
return loginState{}, errors.New("login cookie expired")
}
return s, nil
}
// returnPath validates the optional post-login return path. Empty means "/".
// Only same-origin absolute paths are allowed: no scheme, host, protocol-
// relative "//" prefix, backslashes or control characters.
func returnPath(raw string) (string, error) {
if raw == "" {
return "/", nil
}
if !strings.HasPrefix(raw, "/") || strings.HasPrefix(raw, "//") || strings.ContainsRune(raw, '\\') {
return "", errors.New("return path must be a same-origin path")
}
for _, c := range raw {
if c < 0x20 || c == 0x7f {
return "", errors.New("return path contains a control character")
}
}
u, err := url.Parse(raw)
if err != nil || u.Scheme != "" || u.Host != "" || u.User != nil {
return "", errors.New("return path must be a same-origin path")
}
return raw, nil
}
-264
View File
@@ -1,264 +0,0 @@
package auth
import (
"context"
"errors"
"io"
"log/slog"
"net/http"
"net/url"
"slices"
"strings"
"testing"
)
// signInWith completes a login whose ID token has claims edited by edit.
func (h *harness) signInWith(t *testing.T, edit func(claims map[string]any)) *http.Response {
t.Helper()
l := h.login(t, LoginPath)
c := h.provider.claims(l.query.Get("nonce"))
edit(c)
return h.callback(l, l.query.Get("state"), h.provider.signRS256(c))
}
// assertRefused checks resp is the refused-sign-in page and starts no
// session.
func assertRefused(t *testing.T, resp *http.Response) {
t.Helper()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("callback status = %d, want 403", resp.StatusCode)
}
if ct := resp.Header.Get("Content-Type"); ct != "text/html; charset=utf-8" {
t.Errorf("Content-Type = %q, want text/html; charset=utf-8", ct)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{
"This kitchen is for the household only",
`<form method="post" action="/auth/logout">`,
"Sign out",
} {
if !strings.Contains(string(body), want) {
t.Errorf("refused page lacks %q:\n%s", want, body)
}
}
for _, c := range resp.Cookies() {
// Clearing a session cookie (negative Max-Age) is expected.
if (c.Name == SessionCookieName || c.Name == "test_session") && c.MaxAge >= 0 {
t.Errorf("refused sign-in set session cookie %s", c.Name)
}
}
assertLoginCookieCleared(t, resp)
}
func TestGroupGateMember(t *testing.T) {
t.Parallel()
// Any one allowed group is enough, alongside groups that are not allowed.
h := newHarness(t)
resp := h.signInWith(t, func(c map[string]any) { c["groups"] = []string{"guests", "mealquest-family"} })
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("callback status = %d, want 303", resp.StatusCode)
}
if started := h.sessions.started(); len(started) != 1 || !slices.Equal(started[0].Groups, []string{"guests", "mealquest-family"}) {
t.Errorf("sessions started = %+v, want one with the ID token groups", started)
}
if n := h.provider.userinfoRequests(); n != 0 {
t.Errorf("userinfo requested %d times, want 0 when the ID token has groups", n)
}
}
func TestGroupGateRefused(t *testing.T) {
t.Parallel()
tests := []struct {
name string
groups any // ID token groups claim; nil removes it
userinfo map[string]any
}{
{name: "non-member", groups: []string{"guests", "MEALQUEST-ADMIN"}},
{name: "empty groups claim", groups: []string{}},
{name: "groups claim missing from ID token and userinfo", groups: nil},
{
name: "non-member in userinfo",
groups: nil,
userinfo: map[string]any{"sub": "user-123", "groups": []string{"guests"}},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
h := newHarness(t)
if tc.userinfo != nil {
h.provider.setUserinfo(tc.userinfo)
}
resp := h.signInWith(t, func(c map[string]any) {
if tc.groups == nil {
delete(c, "groups")
} else {
c["groups"] = tc.groups
}
})
assertRefused(t, resp)
if started := h.sessions.started(); len(started) != 0 {
t.Errorf("sessions started = %+v, want none", started)
}
if !strings.Contains(h.logs.String(), "sign-in refused") {
t.Errorf("refusal not logged:\n%s", h.logs.String())
}
})
}
}
func TestGroupGateUsesUserinfoWhenIDTokenHasNoGroups(t *testing.T) {
t.Parallel()
h := newHarness(t)
h.provider.setUserinfo(map[string]any{"sub": "user-123", "groups": []string{"mealquest-admin"}})
resp := h.signInWith(t, func(c map[string]any) { delete(c, "groups") })
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("callback status = %d, want 303", resp.StatusCode)
}
if started := h.sessions.started(); len(started) != 1 || !slices.Equal(started[0].Groups, []string{"mealquest-admin"}) {
t.Errorf("sessions started = %+v, want one with the userinfo groups", started)
}
if n := h.provider.userinfoRequests(); n != 1 {
t.Errorf("userinfo requested %d times, want 1", n)
}
}
func TestGroupGateUserinfoFailures(t *testing.T) {
t.Parallel()
tests := []struct {
name string
userinfo map[string]any
}{
{"subject mismatch", map[string]any{"sub": "someone-else", "groups": []string{"mealquest-admin"}}},
{"malformed groups", map[string]any{"sub": "user-123", "groups": "mealquest-admin"}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
h := newHarness(t)
h.provider.setUserinfo(tc.userinfo)
resp := h.signInWith(t, func(c map[string]any) { delete(c, "groups") })
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("callback status = %d, want 401", resp.StatusCode)
}
if started := h.sessions.started(); len(started) != 0 {
t.Errorf("sessions started = %+v, want none", started)
}
})
}
}
// A failed userinfo request fails the sign-in without logging the
// endpoint's response, which may echo credentials.
func TestGroupGateUserinfoErrorNotLogged(t *testing.T) {
t.Parallel()
h := newHarness(t)
h.provider.mu.Lock()
h.provider.userinfoReply = &tokenReply{status: http.StatusInternalServerError, body: `{"error":"leaked-s3cret"}`}
h.provider.mu.Unlock()
resp := h.signInWith(t, func(c map[string]any) { delete(c, "groups") })
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("callback status = %d, want 401", resp.StatusCode)
}
if started := h.sessions.started(); len(started) != 0 {
t.Errorf("sessions started = %+v, want none", started)
}
logs := h.logs.String()
if !strings.Contains(logs, "userinfo") || strings.Contains(logs, "leaked-s3cret") {
t.Errorf("logs = %s, want the userinfo failure without the response body", logs)
}
}
// Membership is checked at every login: after removal from the group, the
// next login is refused and starts no session.
func TestGroupGateRemovedMemberRefusedAtNextLogin(t *testing.T) {
t.Parallel()
h := newSessionHarness(t)
h.signIn(t)
if n := len(h.rows(t)); n != 1 {
t.Fatalf("sessions after first login = %d, want 1", n)
}
resp := h.signInWith(t, func(c map[string]any) { c["groups"] = []string{"former-household"} })
assertRefused(t, resp)
if n := len(h.rows(t)); n != 1 {
t.Errorf("sessions after refused login = %d, want still 1", n)
}
}
// A refused sign-in from a browser that still holds a session ends that
// session and clears its cookie.
func TestGroupGateRefusalEndsExistingSession(t *testing.T) {
t.Parallel()
h := newSessionHarness(t)
_, cookie := h.signIn(t)
l := h.login(t, LoginPath)
c := h.provider.claims(l.query.Get("nonce"))
c["groups"] = []string{"former-household"}
code := h.provider.authorize(l.query.Get("code_challenge"), h.provider.signRS256(c))
q := url.Values{"code": {code}, "state": {l.query.Get("state")}}
resp := h.get(CallbackPath+"?"+q.Encode(), l.cookie, cookie)
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("callback status = %d, want 403", resp.StatusCode)
}
if n := len(h.rows(t)); n != 0 {
t.Errorf("sessions after refused login = %d, want 0", n)
}
if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) {
t.Errorf("old session lookup error = %v, want ErrNoSession", err)
}
cleared := false
for _, c := range resp.Cookies() {
cleared = cleared || (c.Name == SessionCookieName && c.MaxAge < 0)
}
if !cleared {
t.Error("refused sign-in did not clear the session cookie")
}
}
func TestGroupGateRefusalEndSessionError(t *testing.T) {
t.Parallel()
h := newHarness(t)
h.sessions.err = errors.New("database down")
resp := h.signInWith(t, func(c map[string]any) { c["groups"] = []string{"guests"} })
if resp.StatusCode != http.StatusInternalServerError {
t.Errorf("callback status = %d, want 500", resp.StatusCode)
}
}
func TestNewRequiresAllowedGroups(t *testing.T) {
t.Parallel()
p := newFakeProvider(t)
_, err := New(context.Background(), Config{
Issuer: p.issuer(),
ClientID: testClientID,
ClientSecret: testClientSecret,
PublicURL: testPublicURL,
SecretKey: []byte("k"),
}, &memorySessions{}, slog.New(slog.DiscardHandler))
if err == nil || !strings.Contains(err.Error(), "allowed group") {
t.Errorf("New error = %v, want missing allowed groups named", err)
}
}
func TestNewRequiresUserinfoEndpoint(t *testing.T) {
t.Parallel()
p := newFakeProvider(t)
p.discoveryOverrides = map[string]any{"userinfo_endpoint": nil}
_, err := New(context.Background(), Config{
Issuer: p.issuer(),
ClientID: testClientID,
ClientSecret: testClientSecret,
PublicURL: testPublicURL,
SecretKey: []byte("k"),
AllowedGroups: testAllowedGroups,
}, &memorySessions{}, slog.New(slog.DiscardHandler))
if err == nil || !strings.Contains(err.Error(), "userinfo endpoint") {
t.Errorf("New error = %v, want the missing userinfo endpoint named", err)
}
}
-37
View File
@@ -1,37 +0,0 @@
package auth
import (
"io"
"net/http"
)
// refusedPage is shown when a sign-in succeeds at the provider but the
// account is not in the household's group. Signing out ends the provider
// session too, so another account can sign in.
const refusedPage = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MealQuest</title>
</head>
<body>
<main>
<h1>This kitchen is for the household only</h1>
<p>The account you signed in with is not part of our kitchen.</p>
<form method="post" action="` + LogoutPath + `">
<button type="submit">Sign out</button>
</form>
</main>
</body>
</html>
`
// refuse writes the refused-sign-in page with 403.
func (a *Auth) refuse(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusForbidden)
if _, err := io.WriteString(w, refusedPage); err != nil {
a.logger.WarnContext(r.Context(), "write refused sign-in page", "error", err)
}
}
-238
View File
@@ -1,238 +0,0 @@
package auth
import (
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// SessionCookieName is the session cookie. The __Host- prefix makes the
// browser insist on Secure, Path=/ and no Domain.
const SessionCookieName = "__Host-mealquest_session"
// sessionIDBytes is the size of the random session ID: 256 bits.
const sessionIDBytes = 32
// ErrNoSession means the request carries no live session: the cookie is
// missing or malformed, or its session is unknown, ended or expired.
var ErrNoSession = errors.New("auth: no valid session")
// Session is a signed-in household member's server-side session.
type Session struct {
Subject string
Email string
Name string
Groups []string
CSRFToken string
CreatedAt time.Time
// LastSeenAt is the last time the session was used.
LastSeenAt time.Time
// ExpiresAt is when the session ends unless used again first.
ExpiresAt time.Time
}
// SessionManager keeps sessions in the sessions table. The cookie holds only
// the random session ID; the table holds only its SHA-256 hash. A session
// ends after idle without use and always max after it was created.
type SessionManager struct {
db *store.Store
idle time.Duration
max time.Duration
// now is the clock; tests replace it.
now func() time.Time
}
// NewSessionManager returns a SessionManager over db. idle and max must be
// positive with max >= idle.
func NewSessionManager(db *store.Store, idle, max time.Duration) (*SessionManager, error) {
switch {
case db == nil:
return nil, errors.New("auth: session store is required")
case idle <= 0 || max <= 0:
return nil, errors.New("auth: session idle and max lifetimes must be positive")
case max < idle:
return nil, errors.New("auth: session max lifetime must be at least the idle lifetime")
}
return &SessionManager{db: db, idle: idle, max: max, now: time.Now}, nil
}
// StartSession creates a session for claims and sets the session cookie.
func (m *SessionManager) StartSession(w http.ResponseWriter, r *http.Request, claims Claims) error {
if claims.Subject == "" {
return errors.New("auth: session needs a subject")
}
id, csrf := randomToken(), randomToken()
groups := claims.Groups
if groups == nil {
groups = []string{}
}
groupsJSON, err := json.Marshal(groups)
if err != nil {
return fmt.Errorf("auth: encode session groups: %w", err)
}
now := m.timestamp()
if _, err := m.db.Writer.ExecContext(r.Context(), `
INSERT INTO sessions (id_hash, subject, email, name, groups, csrf_token, created_at, last_seen_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
hashSessionID(id), claims.Subject, claims.Email, claims.Name, string(groupsJSON), csrf,
now, now, m.expiry(now.Time, now.Time),
); err != nil {
return fmt.Errorf("auth: create session: %w", err)
}
// The cookie outlives idle gaps so a returning device presents its ID;
// the server decides whether the session is still live. Max-Age is whole
// seconds, rounded up so the cookie never ends before the absolute limit.
http.SetCookie(w, sessionCookie(id, cookieMaxAge(m.max)))
return nil
}
// cookieMaxAge is lifetime in whole seconds, rounded up. It rounds the
// quotient rather than adding to lifetime, which would overflow near the
// largest time.Duration.
func cookieMaxAge(lifetime time.Duration) int {
seconds := lifetime / time.Second
if lifetime%time.Second != 0 {
seconds++
}
return int(seconds)
}
// SessionFor returns the live session r carries and slides its last use to
// now. It returns ErrNoSession when there is none; an expired session is
// deleted.
func (m *SessionManager) SessionFor(r *http.Request) (Session, error) {
idHash, err := sessionIDHash(r)
if err != nil {
return Session{}, err
}
now := m.timestamp()
var s Session
live := false
err = m.db.WithTx(r.Context(), func(tx *sql.Tx) error {
var groupsJSON string
var created, lastSeen, expires store.Timestamp
err := tx.QueryRowContext(r.Context(), `
SELECT subject, email, name, groups, csrf_token, created_at, last_seen_at, expires_at
FROM sessions WHERE id_hash = ?`, idHash,
).Scan(&s.Subject, &s.Email, &s.Name, &groupsJSON, &s.CSRFToken, &created, &lastSeen, &expires)
if errors.Is(err, sql.ErrNoRows) {
return nil
}
if err != nil {
return fmt.Errorf("auth: read session: %w", err)
}
if !now.Before(expires.Time) || now.Sub(lastSeen.Time) >= m.idle || now.Sub(created.Time) >= m.max {
if _, err := tx.ExecContext(r.Context(), `DELETE FROM sessions WHERE id_hash = ?`, idHash); err != nil {
return fmt.Errorf("auth: delete expired session: %w", err)
}
return nil
}
if err := json.Unmarshal([]byte(groupsJSON), &s.Groups); err != nil {
return fmt.Errorf("auth: decode session groups: %w", err)
}
expiresAt := m.expiry(created.Time, now.Time)
if _, err := tx.ExecContext(r.Context(), `
UPDATE sessions SET last_seen_at = ?, expires_at = ? WHERE id_hash = ?`,
now, expiresAt, idHash,
); err != nil {
return fmt.Errorf("auth: slide session: %w", err)
}
s.CreatedAt, s.LastSeenAt, s.ExpiresAt = created.Time, now.Time, expiresAt.Time
live = true
return nil
})
if err != nil {
return Session{}, err
}
if !live {
return Session{}, ErrNoSession
}
return s, nil
}
// EndSession deletes the session r carries, if any, and clears the cookie.
func (m *SessionManager) EndSession(w http.ResponseWriter, r *http.Request) error {
http.SetCookie(w, sessionCookie("", -1))
idHash, err := sessionIDHash(r)
if errors.Is(err, ErrNoSession) {
return nil
}
if err != nil {
return err
}
if _, err := m.db.Writer.ExecContext(r.Context(), `DELETE FROM sessions WHERE id_hash = ?`, idHash); err != nil {
return fmt.Errorf("auth: delete session: %w", err)
}
return nil
}
// timestamp is the clock's current instant at stored precision.
func (m *SessionManager) timestamp() store.Timestamp {
return store.Timestamp{Time: m.now().UTC().Truncate(time.Millisecond)}
}
// expiry is when a session created at created and last used at lastSeen
// ends: idle after lastSeen, but never later than max after created.
func (m *SessionManager) expiry(created, lastSeen time.Time) store.Timestamp {
idle, absolute := lastSeen.Add(m.idle), created.Add(m.max)
if absolute.Before(idle) {
return store.Timestamp{Time: absolute}
}
return store.Timestamp{Time: idle}
}
// sessionCookie is the session cookie carrying value, with maxAge seconds;
// a negative maxAge clears it.
func sessionCookie(value string, maxAge int) *http.Cookie {
return &http.Cookie{
Name: SessionCookieName,
Value: value,
Path: "/",
MaxAge: maxAge,
Secure: true,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
}
}
// sessionIDHash returns the stored hash of r's session ID, or ErrNoSession
// when the cookie is missing or is not a well-formed ID.
func sessionIDHash(r *http.Request) (string, error) {
c, err := r.Cookie(SessionCookieName)
if err != nil {
return "", ErrNoSession
}
raw, err := base64.RawURLEncoding.DecodeString(c.Value)
if err != nil || len(raw) != sessionIDBytes {
return "", ErrNoSession
}
return hashSessionID(c.Value), nil
}
// hashSessionID is the SHA-256 hex digest of the cookie value.
func hashSessionID(id string) string {
sum := sha256.Sum256([]byte(id))
return hex.EncodeToString(sum[:])
}
// randomToken returns 256 random bits, base64url-encoded without padding.
// crypto/rand.Read never returns an error; it crashes the program instead.
func randomToken() string {
b := make([]byte, sessionIDBytes)
rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
}
-523
View File
@@ -1,523 +0,0 @@
package auth
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"slices"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
)
const (
testIdle = 30 * 24 * time.Hour
testMax = 180 * 24 * time.Hour
)
// testEpoch is the fake clock's starting instant.
var testEpoch = time.Date(2026, 10, 3, 18, 30, 0, 0, time.UTC)
// sessionHarness runs the real login flow against a fake provider with a
// SessionManager over a migrated database and a fake clock.
type sessionHarness struct {
*harness
db *store.Store
sessions *SessionManager
auth *Auth
clock time.Time
}
func newSessionHarness(t *testing.T) *sessionHarness {
t.Helper()
ctx := context.Background()
db, err := store.Open(ctx, t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := db.Close(); err != nil {
t.Error(err)
}
})
if err := store.Migrate(ctx, db.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
sessions, err := NewSessionManager(db, testIdle, testMax)
if err != nil {
t.Fatal(err)
}
h := &sessionHarness{db: db, sessions: sessions, clock: testEpoch}
sessions.now = func() time.Time { return h.clock }
p := newFakeProvider(t)
logs := &syncBuffer{}
a, err := New(ctx, Config{
Issuer: p.issuer(),
ClientID: testClientID,
ClientSecret: testClientSecret,
PublicURL: testPublicURL,
SecretKey: []byte("server-secret-key"),
AllowedGroups: testAllowedGroups,
}, sessions, slog.New(slog.NewTextHandler(logs, nil)))
if err != nil {
t.Fatal(err)
}
r := chi.NewRouter()
a.Mount(r)
h.harness = &harness{provider: p, router: r, logs: logs}
h.auth = a
return h
}
func (h *sessionHarness) advance(d time.Duration) { h.clock = h.clock.Add(d) }
// signIn completes a login and returns the callback response and the
// session cookie it set.
func (h *sessionHarness) signIn(t *testing.T) (*http.Response, *http.Cookie) {
t.Helper()
l := h.login(t, LoginPath)
resp := h.callback(l, l.query.Get("state"), h.provider.signRS256(h.provider.claims(l.query.Get("nonce"))))
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("callback status = %d, want 303", resp.StatusCode)
}
for _, c := range resp.Cookies() {
if c.Name == SessionCookieName {
return resp, c
}
}
t.Fatalf("callback set no %s cookie", SessionCookieName)
return nil, nil
}
// use looks up the session cookie c names.
func (h *sessionHarness) use(c *http.Cookie) (Session, error) {
req := httptest.NewRequest(http.MethodGet, "/api/anything", nil)
if c != nil {
req.AddCookie(c)
}
return h.sessions.SessionFor(req)
}
type sessionRow struct {
idHash, subject, email, name, groups, csrf string
created, lastSeen, expires store.Timestamp
}
func (h *sessionHarness) rows(t *testing.T) []sessionRow {
t.Helper()
rs, err := h.db.Reader.Query(`SELECT id_hash, subject, email, name, groups, csrf_token,
created_at, last_seen_at, expires_at FROM sessions`)
if err != nil {
t.Fatal(err)
}
defer rs.Close()
var out []sessionRow
for rs.Next() {
var r sessionRow
if err := rs.Scan(&r.idHash, &r.subject, &r.email, &r.name, &r.groups, &r.csrf,
&r.created, &r.lastSeen, &r.expires); err != nil {
t.Fatal(err)
}
out = append(out, r)
}
if err := rs.Err(); err != nil {
t.Fatal(err)
}
return out
}
// sessionSetCookie returns the raw Set-Cookie header for the session cookie.
func sessionSetCookie(t *testing.T, resp *http.Response) string {
t.Helper()
for _, v := range resp.Header.Values("Set-Cookie") {
if strings.HasPrefix(v, SessionCookieName+"=") {
return v
}
}
t.Fatalf("no Set-Cookie for %s in %q", SessionCookieName, resp.Header.Values("Set-Cookie"))
return ""
}
// assertSessionCookieAttributes checks the attributes the __Host- prefix
// and D-004 require, on the raw header so nothing is lost in parsing.
func assertSessionCookieAttributes(t *testing.T, raw string) {
t.Helper()
attrs := map[string]bool{}
for _, a := range strings.Split(raw, ";")[1:] {
name, _, _ := strings.Cut(strings.TrimSpace(a), "=")
attrs[strings.ToLower(name)] = true
}
for _, want := range []string{"Path=/", "HttpOnly", "Secure", "SameSite=Lax"} {
if !slices.Contains(strings.Split(raw, "; "), want) {
t.Errorf("session Set-Cookie %q lacks %s", raw, want)
}
}
if attrs["domain"] {
t.Errorf("session Set-Cookie %q has a Domain", raw)
}
}
func sha256Hex(s string) string {
sum := sha256.Sum256([]byte(s))
return hex.EncodeToString(sum[:])
}
func TestCallbackStartsSession(t *testing.T) {
t.Parallel()
h := newSessionHarness(t)
resp, cookie := h.signIn(t)
assertSessionCookieAttributes(t, sessionSetCookie(t, resp))
if cookie.MaxAge != int(testMax/time.Second) {
t.Errorf("session cookie MaxAge = %d, want %d", cookie.MaxAge, int(testMax/time.Second))
}
rows := h.rows(t)
if len(rows) != 1 {
t.Fatalf("sessions has %d rows, want 1", len(rows))
}
r := rows[0]
if len(cookie.Value) != 43 {
t.Errorf("session ID %q is %d chars, want 43 (256 bits, base64url)", cookie.Value, len(cookie.Value))
}
if r.idHash != sha256Hex(cookie.Value) {
t.Errorf("id_hash = %q, want the SHA-256 of the cookie value", r.idHash)
}
if r.subject != "user-123" || r.email != "cook@example.com" || r.name != "Home Cook" ||
r.groups != `["mealquest-admin"]` {
t.Errorf("session row = %+v, want the ID token claims", r)
}
if len(r.csrf) != 43 || r.csrf == cookie.Value {
t.Errorf("csrf_token = %q, want a separate 256-bit token", r.csrf)
}
if !r.created.Equal(testEpoch) || !r.lastSeen.Equal(testEpoch) || !r.expires.Equal(testEpoch.Add(testIdle)) {
t.Errorf("created, last_seen, expires = %v, %v, %v, want %v, %v, %v",
r.created, r.lastSeen, r.expires, testEpoch, testEpoch, testEpoch.Add(testIdle))
}
s, err := h.use(cookie)
if err != nil {
t.Fatalf("SessionFor = %v, want the new session", err)
}
if s.Subject != "user-123" || s.CSRFToken != r.csrf || !slices.Equal(s.Groups, []string{"mealquest-admin"}) {
t.Errorf("SessionFor = %+v", s)
}
}
func TestStartSessionStoresOnlyTheHash(t *testing.T) {
t.Parallel()
h := newSessionHarness(t)
_, cookie := h.signIn(t)
var n int
err := h.db.Reader.QueryRow(`SELECT count(*) FROM sessions WHERE
id_hash = ?1 OR subject = ?1 OR email = ?1 OR name = ?1 OR groups = ?1 OR csrf_token = ?1`,
cookie.Value).Scan(&n)
if err != nil {
t.Fatal(err)
}
if n != 0 {
t.Error("the raw session ID is stored in the sessions table")
}
}
func TestSessionLastSeenSlides(t *testing.T) {
t.Parallel()
h := newSessionHarness(t)
_, cookie := h.signIn(t)
// Used every 20 days, the session outlives several idle periods.
for range 4 {
h.advance(20 * 24 * time.Hour)
s, err := h.use(cookie)
if err != nil {
t.Fatalf("at %v SessionFor = %v, want the session", h.clock, err)
}
if !s.LastSeenAt.Equal(h.clock) || !s.ExpiresAt.Equal(h.clock.Add(testIdle)) {
t.Errorf("at %v session last seen %v, expires %v, want %v, %v",
h.clock, s.LastSeenAt, s.ExpiresAt, h.clock, h.clock.Add(testIdle))
}
r := h.rows(t)[0]
if !r.lastSeen.Equal(h.clock) || !r.expires.Equal(h.clock.Add(testIdle)) || !r.created.Equal(testEpoch) {
t.Errorf("at %v row created %v, last seen %v, expires %v", h.clock, r.created, r.lastSeen, r.expires)
}
}
}
func TestSessionIdleExpiry(t *testing.T) {
t.Parallel()
h := newSessionHarness(t)
_, cookie := h.signIn(t)
h.advance(testIdle - time.Millisecond)
if _, err := h.use(cookie); err != nil {
t.Fatalf("just inside the idle limit SessionFor = %v, want the session", err)
}
h.advance(testIdle)
if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) {
t.Fatalf("after idle SessionFor = %v, want ErrNoSession", err)
}
if n := len(h.rows(t)); n != 0 {
t.Errorf("expired session left %d rows, want 0", n)
}
// Turning the clock back cannot revive it.
h.clock = testEpoch
if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) {
t.Errorf("after deletion SessionFor = %v, want ErrNoSession", err)
}
}
func TestSessionAbsoluteExpiry(t *testing.T) {
t.Parallel()
h := newSessionHarness(t)
_, cookie := h.signIn(t)
for h.clock.Before(testEpoch.Add(testMax - 20*24*time.Hour)) {
h.advance(20 * 24 * time.Hour)
if _, err := h.use(cookie); err != nil {
t.Fatalf("at %v SessionFor = %v, want the session", h.clock, err)
}
}
// Last used 20 days before the absolute limit: the idle window would run
// past it, so expiry is capped there.
if r := h.rows(t)[0]; !r.expires.Equal(testEpoch.Add(testMax)) {
t.Errorf("expires_at = %v, want the absolute limit %v", r.expires, testEpoch.Add(testMax))
}
h.clock = testEpoch.Add(testMax - time.Millisecond)
if _, err := h.use(cookie); err != nil {
t.Fatalf("just inside the absolute limit SessionFor = %v, want the session", err)
}
h.clock = testEpoch.Add(testMax)
if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) {
t.Fatalf("at the absolute limit, used 1ms ago, SessionFor = %v, want ErrNoSession", err)
}
if n := len(h.rows(t)); n != 0 {
t.Errorf("expired session left %d rows, want 0", n)
}
}
// A max with fractional seconds rounds the cookie Max-Age up, so the browser
// keeps the cookie until the server-side absolute limit.
func TestSessionCookieOutlivesFractionalMax(t *testing.T) {
t.Parallel()
h := newSessionHarness(t)
h.sessions.idle, h.sessions.max = 1500*time.Millisecond, 2500*time.Millisecond
_, cookie := h.signIn(t)
if cookie.MaxAge != 3 {
t.Errorf("session cookie MaxAge = %d, want 3", cookie.MaxAge)
}
if r := h.rows(t)[0]; !r.expires.Equal(testEpoch.Add(1500 * time.Millisecond)) {
t.Errorf("expires_at = %v, want %v", r.expires, testEpoch.Add(1500*time.Millisecond))
}
h.advance(time.Second)
if _, err := h.use(cookie); err != nil {
t.Fatalf("at 1s SessionFor = %v, want the session", err)
}
h.clock = testEpoch.Add(2500*time.Millisecond - time.Millisecond)
if _, err := h.use(cookie); err != nil {
t.Fatalf("just inside the absolute limit SessionFor = %v, want the session", err)
}
h.clock = testEpoch.Add(2500 * time.Millisecond)
if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) {
t.Errorf("at the absolute limit SessionFor = %v, want ErrNoSession", err)
}
}
// A lifetime near the largest accepted duration still yields a positive,
// rounded-up cookie Max-Age rather than overflowing.
func TestSessionCookieMaxAgeNearLargestMax(t *testing.T) {
t.Parallel()
largest, err := time.ParseDuration("2562047h47m16.854s")
if err != nil {
t.Fatal(err)
}
h := newSessionHarness(t)
h.sessions.max = largest
_, cookie := h.signIn(t)
if want := 9223372037; cookie.MaxAge != want {
t.Errorf("session cookie MaxAge = %d, want %d", cookie.MaxAge, want)
}
}
// A lifetime shortened by configuration applies to existing sessions even
// though their stored expires_at is later.
func TestSessionLifetimeChecksUseCurrentConfig(t *testing.T) {
t.Parallel()
h := newSessionHarness(t)
_, cookie := h.signIn(t)
h.sessions.idle = 24 * time.Hour
h.advance(24 * time.Hour)
if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) {
t.Errorf("past the shortened idle SessionFor = %v, want ErrNoSession", err)
}
_, cookie = h.signIn(t)
h.sessions.idle, h.sessions.max = 24*time.Hour, 48*time.Hour
for range 3 {
h.advance(12 * time.Hour)
if _, err := h.use(cookie); err != nil {
t.Fatalf("SessionFor = %v, want the session", err)
}
}
h.advance(12 * time.Hour)
if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) {
t.Errorf("past the shortened max SessionFor = %v, want ErrNoSession", err)
}
// Used recently and with expires_at still ahead, but older than a max
// shortened after that use: only the absolute check ends it.
h.sessions.idle, h.sessions.max = testIdle, testMax
_, cookie = h.signIn(t)
h.advance(10 * 24 * time.Hour)
if _, err := h.use(cookie); err != nil {
t.Fatalf("SessionFor = %v, want the session", err)
}
h.sessions.max = 10 * 24 * time.Hour
h.advance(time.Hour)
if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) {
t.Errorf("past a max shortened after the last use SessionFor = %v, want ErrNoSession", err)
}
}
func TestSessionForRejectsBadCookies(t *testing.T) {
t.Parallel()
h := newSessionHarness(t)
h.signIn(t)
for name, c := range map[string]*http.Cookie{
"missing": nil,
"empty": {Name: SessionCookieName, Value: ""},
"not base64": {Name: SessionCookieName, Value: strings.Repeat("!", 43)},
"short": {Name: SessionCookieName, Value: "AAAA"},
"unknown ID": {Name: SessionCookieName, Value: randomToken()},
"other cookie": {Name: "mealquest_session", Value: randomToken()},
} {
if _, err := h.use(c); !errors.Is(err, ErrNoSession) {
t.Errorf("%s: SessionFor = %v, want ErrNoSession", name, err)
}
}
if n := len(h.rows(t)); n != 1 {
t.Errorf("sessions has %d rows, want the signed-in session only", n)
}
}
func (h *sessionHarness) logout(cookies ...*http.Cookie) *http.Response {
req := httptest.NewRequest(http.MethodPost, LogoutPath, nil)
for _, c := range cookies {
req.AddCookie(c)
}
rec := httptest.NewRecorder()
h.router.ServeHTTP(rec, req)
return rec.Result()
}
func TestLogout(t *testing.T) {
t.Parallel()
h := newSessionHarness(t)
_, cookie := h.signIn(t)
_, other := h.signIn(t)
resp := h.logout(cookie)
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("logout status = %d, want 303", resp.StatusCode)
}
want := h.provider.issuer() + "/end-session/"
if got := resp.Header.Get("Location"); got != want || h.auth.EndSessionURL() != want {
t.Errorf("logout redirect = %q, EndSessionURL = %q, want %q", got, h.auth.EndSessionURL(), want)
}
raw := sessionSetCookie(t, resp)
assertSessionCookieAttributes(t, raw)
if !strings.HasPrefix(raw, SessionCookieName+"=;") || !strings.Contains(raw, "Max-Age=0") {
t.Errorf("logout Set-Cookie = %q, want the cookie cleared", raw)
}
rows := h.rows(t)
if len(rows) != 1 || rows[0].idHash != sha256Hex(other.Value) {
t.Errorf("after logout sessions = %+v, want only the other session", rows)
}
if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) {
t.Errorf("logged-out cookie: SessionFor = %v, want ErrNoSession", err)
}
if _, err := h.use(other); err != nil {
t.Errorf("other session: SessionFor = %v, want it untouched", err)
}
}
func TestLogoutWithoutSession(t *testing.T) {
t.Parallel()
h := newSessionHarness(t)
resp := h.logout()
if resp.StatusCode != http.StatusSeeOther || resp.Header.Get("Location") != h.provider.issuer()+"/end-session/" {
t.Errorf("logout = %d to %q, want 303 to the end session endpoint", resp.StatusCode, resp.Header.Get("Location"))
}
assertSessionCookieAttributes(t, sessionSetCookie(t, resp))
}
func TestLogoutIsPostOnly(t *testing.T) {
t.Parallel()
h := newSessionHarness(t)
_, cookie := h.signIn(t)
if resp := h.get(LogoutPath, cookie); resp.StatusCode != http.StatusMethodNotAllowed {
t.Errorf("GET %s status = %d, want 405", LogoutPath, resp.StatusCode)
}
if _, err := h.use(cookie); err != nil {
t.Errorf("after GET %s SessionFor = %v, want the session", LogoutPath, err)
}
}
func TestLogoutSessionError(t *testing.T) {
t.Parallel()
h := newHarness(t)
h.sessions.err = errors.New("database down")
rec := httptest.NewRecorder()
h.router.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, LogoutPath, nil))
if rec.Code != http.StatusInternalServerError {
t.Errorf("logout status = %d, want 500", rec.Code)
}
}
func TestNewRequiresEndSessionEndpoint(t *testing.T) {
t.Parallel()
p := newFakeProvider(t)
p.discoveryOverrides = map[string]any{"end_session_endpoint": nil}
_, err := New(context.Background(), Config{
Issuer: p.issuer(),
ClientID: testClientID,
ClientSecret: testClientSecret,
PublicURL: testPublicURL,
SecretKey: []byte("k"),
AllowedGroups: testAllowedGroups,
}, &memorySessions{}, slog.New(slog.DiscardHandler))
if err == nil || !strings.Contains(err.Error(), "end session endpoint") {
t.Errorf("New error = %v, want the missing end session endpoint named", err)
}
}
func TestNewSessionManagerValidates(t *testing.T) {
t.Parallel()
db := &store.Store{}
for _, tc := range []struct {
name string
db *store.Store
idle, max time.Duration
}{
{"nil store", nil, time.Hour, time.Hour},
{"zero idle", db, 0, time.Hour},
{"negative max", db, time.Hour, -time.Hour},
{"max below idle", db, 2 * time.Hour, time.Hour},
} {
if _, err := NewSessionManager(tc.db, tc.idle, tc.max); err == nil {
t.Errorf("%s: NewSessionManager succeeded", tc.name)
}
}
}
+4 -99
View File
@@ -11,8 +11,6 @@ import (
"strconv"
"strings"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secureurl"
)
// Environment variable names.
@@ -22,21 +20,6 @@ const (
EnvDataDir = "MEALQUEST_DATA_DIR"
EnvShutdownTimeout = "MEALQUEST_SHUTDOWN_TIMEOUT"
EnvSecretKeyFile = "MEALQUEST_SECRET_KEY_FILE"
EnvOIDCIssuer = "MEALQUEST_OIDC_ISSUER"
EnvOIDCClientID = "MEALQUEST_OIDC_CLIENT_ID"
EnvOIDCClientSecretFile = "MEALQUEST_OIDC_CLIENT_SECRET_FILE"
EnvOIDCAllowedGroups = "MEALQUEST_OIDC_ALLOWED_GROUPS"
EnvSessionIdle = "MEALQUEST_SESSION_IDLE"
EnvSessionMax = "MEALQUEST_SESSION_MAX"
)
// Session lifetime defaults (D-004): long enough that the kitchen tablet
// stays signed in.
const (
DefaultSessionIdle = 720 * time.Hour // 30 days
DefaultSessionMax = 4320 * time.Hour // 180 days
)
// Config is the validated service configuration.
@@ -46,18 +29,6 @@ type Config struct {
DataDir string
ShutdownTimeout time.Duration
SecretKey Secret
// OIDCIssuer is the Authentik issuer URL used for discovery.
OIDCIssuer *url.URL
OIDCClientID string
OIDCClientSecret Secret
// OIDCAllowedGroups lists the groups whose members may sign in.
OIDCAllowedGroups []string
// SessionIdle ends a session left unused this long.
SessionIdle time.Duration
// SessionMax ends every session this long after sign-in, used or not.
SessionMax time.Duration
}
// PublicOrigin returns the scheme and host of PublicURL, the only parts of it
@@ -76,21 +47,10 @@ func load(lookup func(string) (string, bool)) (Config, error) {
r := reader{lookup: lookup}
cfg := Config{
HTTPAddr: r.listenAddr(EnvHTTPAddr),
PublicURL: r.secureURL(EnvPublicURL),
PublicURL: r.httpURL(EnvPublicURL),
DataDir: r.dir(EnvDataDir),
ShutdownTimeout: r.duration(EnvShutdownTimeout),
SecretKey: r.secretFile(EnvSecretKeyFile),
OIDCIssuer: r.secureURL(EnvOIDCIssuer),
OIDCClientID: r.str(EnvOIDCClientID),
OIDCClientSecret: r.secretFile(EnvOIDCClientSecretFile),
OIDCAllowedGroups: r.list(EnvOIDCAllowedGroups),
SessionIdle: r.sessionDuration(EnvSessionIdle, DefaultSessionIdle),
SessionMax: r.sessionDuration(EnvSessionMax, DefaultSessionMax),
}
if cfg.SessionIdle > 0 && cfg.SessionMax > 0 && cfg.SessionMax < cfg.SessionIdle {
r.fail(EnvSessionMax, "must be at least %s (%s)", EnvSessionIdle, cfg.SessionIdle)
}
if len(r.problems) > 0 {
return Config{}, &Error{Problems: r.problems}
@@ -154,29 +114,6 @@ func (r *reader) required(name string) (string, bool) {
return v, true
}
func (r *reader) str(name string) string {
v, _ := r.required(name)
return v
}
// list parses a comma-separated list. Every entry must be non-empty after
// trimming spaces.
func (r *reader) list(name string) []string {
v, ok := r.required(name)
if !ok {
return nil
}
items := strings.Split(v, ",")
for i, item := range items {
items[i] = strings.TrimSpace(item)
if items[i] == "" {
r.fail(name, "empty entry in comma-separated list %q", v)
return nil
}
}
return items
}
func (r *reader) listenAddr(name string) string {
v, ok := r.required(name)
if !ok {
@@ -218,21 +155,15 @@ func validHostname(h string) bool {
return true
}
// secureURL reads an absolute URL that must pass secureurl.Check: https://,
// or http:// to a loopback host only.
func (r *reader) secureURL(name string) *url.URL {
func (r *reader) httpURL(name string) *url.URL {
v, ok := r.required(name)
if !ok {
return nil
}
// The value is never echoed: it may carry credentials.
u, err := url.Parse(v)
if err != nil {
r.fail(name, "invalid URL (want absolute https:// URL)")
return nil
}
if err := secureurl.Check(u); err != nil {
r.fail(name, "invalid URL: %v", err)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" {
r.fail(name, "invalid URL (want absolute http:// or https:// URL)")
return nil
}
if u.User != nil {
@@ -264,32 +195,6 @@ func (r *reader) duration(name string) time.Duration {
if !ok {
return 0
}
return r.parseDuration(name, v)
}
// optionalDuration returns def when name is unset. A set value, even an empty
// one, must be a valid positive duration.
func (r *reader) optionalDuration(name string, def time.Duration) time.Duration {
v, ok := r.lookup(name)
if !ok {
return def
}
return r.parseDuration(name, v)
}
// sessionDuration is optionalDuration restricted to whole milliseconds:
// session expiry is stored as a store.Timestamp, which keeps millisecond
// precision, so a finer duration could not be honoured.
func (r *reader) sessionDuration(name string, def time.Duration) time.Duration {
d := r.optionalDuration(name, def)
if d%time.Millisecond != 0 {
r.fail(name, "duration must be a whole number of milliseconds, got %s", d)
return 0
}
return d
}
func (r *reader) parseDuration(name, v string) time.Duration {
d, err := time.ParseDuration(v)
if err != nil {
r.fail(name, "invalid duration %q (want e.g. 10s, 1m)", v)
+2 -160
View File
@@ -6,7 +6,6 @@ import (
"net/url"
"os"
"path/filepath"
"reflect"
"slices"
"strings"
"testing"
@@ -22,10 +21,6 @@ func TestLoad(t *testing.T) {
t.Fatal(err)
}
unreadablePath := filepath.Join(dir, "missing_secret")
clientSecretPath := filepath.Join(dir, "oidc_client_secret")
if err := os.WriteFile(clientSecretPath, []byte("client-s3cret\n"), 0o600); err != nil {
t.Fatal(err)
}
emptyPath := filepath.Join(dir, "empty_secret")
if err := os.WriteFile(emptyPath, []byte("\n"), 0o600); err != nil {
t.Fatal(err)
@@ -38,11 +33,6 @@ func TestLoad(t *testing.T) {
EnvDataDir: dir,
EnvShutdownTimeout: "15s",
EnvSecretKeyFile: secretPath,
EnvOIDCIssuer: "https://auth.example.com/application/o/mealquest/",
EnvOIDCClientID: "mealquest",
EnvOIDCClientSecretFile: clientSecretPath,
EnvOIDCAllowedGroups: "mealquest-admin",
}
}
with := func(changes map[string]string, unset ...string) map[string]string {
@@ -80,7 +70,6 @@ func TestLoad(t *testing.T) {
env: map[string]string{},
badVars: []string{
EnvHTTPAddr, EnvPublicURL, EnvDataDir, EnvShutdownTimeout, EnvSecretKeyFile,
EnvOIDCIssuer, EnvOIDCClientID, EnvOIDCClientSecretFile, EnvOIDCAllowedGroups,
},
},
{
@@ -150,24 +139,6 @@ func TestLoad(t *testing.T) {
env: with(map[string]string{EnvPublicURL: "http://:8080"}),
badVars: []string{EnvPublicURL},
},
{
name: "plain HTTP public URL on a remote host",
env: with(map[string]string{EnvPublicURL: "http://meals.example.com"}),
badVars: []string{EnvPublicURL},
},
{
name: "plain HTTP public URL on a non-loopback IP",
env: with(map[string]string{EnvPublicURL: "http://192.168.1.20:8080"}),
badVars: []string{EnvPublicURL},
},
{
name: "plain HTTP public URL on 127.0.0.1",
env: with(map[string]string{EnvPublicURL: "http://127.0.0.1:8080"}),
},
{
name: "plain HTTP public URL on [::1]",
env: with(map[string]string{EnvPublicURL: "http://[::1]:8080"}),
},
{
name: "relative public URL",
env: with(map[string]string{EnvPublicURL: "meals.example.com"}),
@@ -198,101 +169,6 @@ func TestLoad(t *testing.T) {
env: with(map[string]string{"MEALQUEST_SECRET_KEY": "inline"}),
badVars: []string{"MEALQUEST_SECRET_KEY"},
},
{
name: "inline OIDC client secret rejected",
env: with(map[string]string{"MEALQUEST_OIDC_CLIENT_SECRET": "inline"}),
badVars: []string{"MEALQUEST_OIDC_CLIENT_SECRET"},
},
{
name: "unreadable OIDC client secret file",
env: with(map[string]string{EnvOIDCClientSecretFile: unreadablePath}),
badVars: []string{EnvOIDCClientSecretFile},
},
{
name: "relative OIDC issuer",
env: with(map[string]string{EnvOIDCIssuer: "auth.example.com"}),
badVars: []string{EnvOIDCIssuer},
},
{
name: "non-http OIDC issuer",
env: with(map[string]string{EnvOIDCIssuer: "ftp://auth.example.com"}),
badVars: []string{EnvOIDCIssuer},
},
{
name: "plain HTTP OIDC issuer on a remote host",
env: with(map[string]string{EnvOIDCIssuer: "http://auth.example.com/application/o/mealquest/"}),
badVars: []string{EnvOIDCIssuer},
},
{
name: "plain HTTP OIDC issuer on localhost",
env: with(map[string]string{EnvOIDCIssuer: "http://localhost:9000/application/o/mealquest/"}),
},
{
name: "several allowed groups",
env: with(map[string]string{EnvOIDCAllowedGroups: "mealquest-admin, family"}),
},
{
name: "empty allowed group entry",
env: with(map[string]string{EnvOIDCAllowedGroups: "mealquest-admin,,family"}),
badVars: []string{EnvOIDCAllowedGroups},
},
{
name: "trailing comma in allowed groups",
env: with(map[string]string{EnvOIDCAllowedGroups: "mealquest-admin,"}),
badVars: []string{EnvOIDCAllowedGroups},
},
{
name: "blank OIDC client ID",
env: with(map[string]string{EnvOIDCClientID: " "}),
badVars: []string{EnvOIDCClientID},
},
{
name: "session lifetimes set",
env: with(map[string]string{EnvSessionIdle: "24h", EnvSessionMax: "48h"}),
},
{
name: "session max equal to idle",
env: with(map[string]string{EnvSessionIdle: "24h", EnvSessionMax: "24h"}),
},
{
name: "invalid session idle",
env: with(map[string]string{EnvSessionIdle: "a month"}),
badVars: []string{EnvSessionIdle},
},
{
name: "empty session idle",
env: with(map[string]string{EnvSessionIdle: ""}),
badVars: []string{EnvSessionIdle},
},
{
name: "non-positive session max",
env: with(map[string]string{EnvSessionMax: "-1h"}),
badVars: []string{EnvSessionMax},
},
{
name: "whole-millisecond session lifetimes",
env: with(map[string]string{EnvSessionIdle: "1ms", EnvSessionMax: "2ms"}),
},
{
name: "sub-millisecond session idle",
env: with(map[string]string{EnvSessionIdle: "1500us", EnvSessionMax: "2ms"}),
badVars: []string{EnvSessionIdle},
},
{
name: "sub-millisecond session max",
env: with(map[string]string{EnvSessionIdle: "1ms", EnvSessionMax: "1ms500us"}),
badVars: []string{EnvSessionMax},
},
{
name: "session max below idle",
env: with(map[string]string{EnvSessionIdle: "48h", EnvSessionMax: "24h"}),
badVars: []string{EnvSessionMax},
},
{
name: "session max below default idle",
env: with(map[string]string{EnvSessionMax: "719h"}),
badVars: []string{EnvSessionMax},
},
{
name: "several invalid values are all named",
env: with(map[string]string{
@@ -335,7 +211,7 @@ func TestLoad(t *testing.T) {
t.Errorf("error message %q does not name %s", err.Error(), v)
}
}
if !reflect.ValueOf(cfg).IsZero() {
if cfg != (Config{}) {
t.Errorf("load() returned non-zero Config alongside error: %+v", cfg)
}
})
@@ -350,21 +226,12 @@ func TestLoadValues(t *testing.T) {
if err := os.WriteFile(secretPath, []byte("s3cret-value\r\n"), 0o600); err != nil {
t.Fatal(err)
}
clientSecretPath := filepath.Join(dir, "oidc_client_secret")
if err := os.WriteFile(clientSecretPath, []byte("client-s3cret\n"), 0o600); err != nil {
t.Fatal(err)
}
env := map[string]string{
EnvHTTPAddr: "127.0.0.1:9000",
EnvPublicURL: "http://localhost:9000",
EnvDataDir: dir,
EnvShutdownTimeout: "1m30s",
EnvSecretKeyFile: secretPath,
EnvOIDCIssuer: "https://auth.example.com/application/o/mealquest/",
EnvOIDCClientID: "mealquest",
EnvOIDCClientSecretFile: clientSecretPath,
EnvOIDCAllowedGroups: " mealquest-admin ,family",
}
cfg, err := load(func(k string) (string, bool) { v, ok := env[k]; return v, ok })
if err != nil {
@@ -386,31 +253,6 @@ func TestLoadValues(t *testing.T) {
if cfg.SecretKey.Reveal() != "s3cret-value" {
t.Errorf("SecretKey.Reveal() = %q, want trailing newline trimmed", cfg.SecretKey.Reveal())
}
if got, want := cfg.OIDCIssuer.String(), "https://auth.example.com/application/o/mealquest/"; got != want {
t.Errorf("OIDCIssuer = %q, want %q", got, want)
}
if cfg.OIDCClientID != "mealquest" {
t.Errorf("OIDCClientID = %q", cfg.OIDCClientID)
}
if cfg.OIDCClientSecret.Reveal() != "client-s3cret" {
t.Errorf("OIDCClientSecret.Reveal() = %q", cfg.OIDCClientSecret.Reveal())
}
if want := []string{"mealquest-admin", "family"}; !slices.Equal(cfg.OIDCAllowedGroups, want) {
t.Errorf("OIDCAllowedGroups = %q, want %q", cfg.OIDCAllowedGroups, want)
}
if cfg.SessionIdle != 720*time.Hour || cfg.SessionMax != 4320*time.Hour {
t.Errorf("SessionIdle, SessionMax = %v, %v, want defaults 720h, 4320h", cfg.SessionIdle, cfg.SessionMax)
}
env[EnvSessionIdle] = "12h"
env[EnvSessionMax] = "36h"
cfg, err = load(func(k string) (string, bool) { v, ok := env[k]; return v, ok })
if err != nil {
t.Fatalf("load() error = %v", err)
}
if cfg.SessionIdle != 12*time.Hour || cfg.SessionMax != 36*time.Hour {
t.Errorf("SessionIdle, SessionMax = %v, %v, want 12h, 36h", cfg.SessionIdle, cfg.SessionMax)
}
}
func TestSecretRedacted(t *testing.T) {
@@ -418,7 +260,7 @@ func TestSecretRedacted(t *testing.T) {
s := Secret{value: "s3cret-value"}
for _, format := range []string{"%v", "%s", "%+v", "%#v"} {
cfg := Config{SecretKey: s, OIDCClientSecret: s}
cfg := Config{SecretKey: s}
for _, out := range []string{fmt.Sprintf(format, s), fmt.Sprintf(format, cfg)} {
if strings.Contains(out, "s3cret-value") {
t.Errorf("format %s leaked secret: %s", format, out)
+130
View File
@@ -0,0 +1,130 @@
// Package household owns household-local calendar and clock logic. Every
// function works in the household's IANA zone, passed explicitly, and never
// in the process zone (time.Local).
//
// Weeks are ISO weeks: Monday to Sunday, numbered by ISO 8601, so the period
// key of 2026-12-28 is "2026-W53" and of 2027-01-04 is "2027-W01".
//
// Calendar arithmetic is done on UTC midnights, which have no DST, so a date
// plus seven days is always the same weekday a week later.
package household
import (
"errors"
"fmt"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// Today returns the household-local date at instant now in zone loc.
func Today(now time.Time, loc *time.Location) store.LocalDate {
return store.LocalDateOf(now.In(loc))
}
// WeekStart returns the ISO Monday of the week containing d. It panics if d is
// the zero LocalDate.
func WeekStart(d store.LocalDate) store.LocalDate {
t := midnightUTC(d)
// Weekday counts from Sunday = 0; shift so Monday = 0 and Sunday = 6.
sinceMonday := (int(t.Weekday()) + 6) % 7
return store.LocalDateOf(t.AddDate(0, 0, -sinceMonday))
}
// WeekDays returns the seven dates Monday to Sunday of the week starting on
// weekStart. It is an error if weekStart is not a Monday.
func WeekDays(weekStart store.LocalDate) ([7]store.LocalDate, error) {
t, err := monday(weekStart)
if err != nil {
return [7]store.LocalDate{}, err
}
var days [7]store.LocalDate
for i := range days {
days[i] = store.LocalDateOf(t.AddDate(0, 0, i))
}
return days, nil
}
// WeekLabel returns the display label of the week starting on weekStart, such
// as "Week 41 · Oct 5 – 11", or "Week 40 · Sep 28 – Oct 4" when the week
// spans two months. It is an error if weekStart is not a Monday.
func WeekLabel(weekStart store.LocalDate) (string, error) {
start, err := monday(weekStart)
if err != nil {
return "", err
}
_, week := start.ISOWeek()
end := start.AddDate(0, 0, 6)
if end.Month() == start.Month() {
return fmt.Sprintf("Week %d · %s – %d", week, start.Format("Jan 2"), end.Day()), nil
}
return fmt.Sprintf("Week %d · %s – %s", week, start.Format("Jan 2"), end.Format("Jan 2")), nil
}
// PeriodKey returns the ISO week key of the week containing d, such as
// "2026-W41". The year is the ISO week-numbering year, which differs from the
// calendar year around New Year. It panics if d is the zero LocalDate.
func PeriodKey(d store.LocalDate) string {
year, week := midnightUTC(d).ISOWeek()
return fmt.Sprintf("%04d-W%02d", year, week)
}
// LocalTime returns the instant at which household clocks in zone loc read
// hhmm (24-hour "HH:MM") on date d.
//
// DST rule: a wall time that does not exist (spring forward) or occurs twice
// (fall back) is read with the UTC offset in effect before the transition. A
// skipped time therefore moves forward by the length of the gap (02:30 on a
// day clocks jump 02:00→03:00 is 03:30), and a repeated time is its first
// occurrence. The rule assumes at most one offset change in the 48 hours
// around the wall time, which holds for every real zone.
func LocalTime(loc *time.Location, d store.LocalDate, hhmm string) (time.Time, error) {
if d.IsZero() {
return time.Time{}, errors.New("household: zero date")
}
clock, err := time.Parse("15:04", hhmm)
if err != nil || clock.Format("15:04") != hhmm {
return time.Time{}, fmt.Errorf("household: invalid time %q: want HH:MM", hhmm)
}
y, m, day := d.Date()
// wall is the wall-clock reading expressed as if it were UTC; the real
// instant is wall minus the zone's UTC offset at that instant.
wall := time.Date(y, m, day, clock.Hour(), clock.Minute(), 0, 0, time.UTC)
before := offset(wall.Add(-24*time.Hour), loc)
after := offset(wall.Add(24*time.Hour), loc)
early := wall.Add(-before)
late := wall.Add(-after)
// Use the post-transition offset only when the pre-transition reading is
// not a real wall time and the post-transition one is.
if offset(early, loc) != before && offset(late, loc) == after {
return late.In(loc), nil
}
return early.In(loc), nil
}
// offset returns the UTC offset of zone loc at instant t.
func offset(t time.Time, loc *time.Location) time.Duration {
_, secs := t.In(loc).Zone()
return time.Duration(secs) * time.Second
}
// midnightUTC returns d as midnight UTC. It panics if d is the zero LocalDate.
func midnightUTC(d store.LocalDate) time.Time {
if d.IsZero() {
panic("household: zero date")
}
y, m, day := d.Date()
return time.Date(y, m, day, 0, 0, 0, 0, time.UTC)
}
// monday returns weekStart as midnight UTC, or an error if it is not a Monday.
func monday(weekStart store.LocalDate) (time.Time, error) {
if weekStart.IsZero() {
return time.Time{}, errors.New("household: zero week start")
}
t := midnightUTC(weekStart)
if t.Weekday() != time.Monday {
return time.Time{}, fmt.Errorf("household: week start %s is a %s, want Monday", weekStart, t.Weekday())
}
return t, nil
}
+311
View File
@@ -0,0 +1,311 @@
package household
import (
"strings"
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// TestMain runs the package with the process zone set to UTC, so any use of
// time.Local in place of the household zone shows up as a failure.
func TestMain(m *testing.M) {
time.Local = time.UTC
m.Run()
}
func loadZone(t *testing.T, name string) *time.Location {
t.Helper()
loc, err := time.LoadLocation(name)
if err != nil {
t.Fatalf("LoadLocation(%q): %v", name, err)
}
return loc
}
func date(t *testing.T, s string) store.LocalDate {
t.Helper()
d, err := store.ParseLocalDate(s)
if err != nil {
t.Fatal(err)
}
return d
}
func utc(t *testing.T, s string) time.Time {
t.Helper()
ts, err := time.Parse(time.RFC3339, s)
if err != nil {
t.Fatal(err)
}
return ts
}
func TestTodayUsesHouseholdZone(t *testing.T) {
chicago := loadZone(t, "America/Chicago")
auckland := loadZone(t, "Pacific/Auckland")
for _, tc := range []struct {
now string
loc *time.Location
want string
}{
// 2026-10-05T04:30Z is Sunday 23:30 in Chicago (CDT, UTC−5) and
// Monday 17:30 in Auckland (NZDT, UTC+13).
{"2026-10-05T04:30:00Z", chicago, "2026-10-04"},
{"2026-10-05T04:30:00Z", auckland, "2026-10-05"},
// 2026-10-04T11:30Z is Sunday 06:30 in Chicago and already Monday
// 00:30 in Auckland.
{"2026-10-04T11:30:00Z", chicago, "2026-10-04"},
{"2026-10-04T11:30:00Z", auckland, "2026-10-05"},
} {
if got := Today(utc(t, tc.now), tc.loc).String(); got != tc.want {
t.Errorf("Today(%s, %s) = %s, want %s", tc.now, tc.loc, got, tc.want)
}
}
}
func TestSundayNightMondayMorningWeekAssignment(t *testing.T) {
for _, tc := range []struct {
zone, now string
wantDay string
wantWeekStart string
wantPeriodKey string
wantWeekday time.Weekday
}{
// Sunday 23:59 local belongs to the week that started the previous
// Monday; one minute later, Monday 00:00 starts a new week.
{"America/Chicago", "2026-10-12T04:59:00Z", "2026-10-11", "2026-10-05", "2026-W41", time.Sunday},
{"America/Chicago", "2026-10-12T05:00:00Z", "2026-10-12", "2026-10-12", "2026-W42", time.Monday},
{"Pacific/Auckland", "2026-10-11T10:59:00Z", "2026-10-11", "2026-10-05", "2026-W41", time.Sunday},
{"Pacific/Auckland", "2026-10-11T11:00:00Z", "2026-10-12", "2026-10-12", "2026-W42", time.Monday},
} {
loc := loadZone(t, tc.zone)
now := utc(t, tc.now)
if wd := now.In(loc).Weekday(); wd != tc.wantWeekday {
t.Fatalf("%s in %s is %s, want %s", tc.now, tc.zone, wd, tc.wantWeekday)
}
today := Today(now, loc)
if today.String() != tc.wantDay {
t.Errorf("Today(%s, %s) = %s, want %s", tc.now, tc.zone, today, tc.wantDay)
}
if got := WeekStart(today).String(); got != tc.wantWeekStart {
t.Errorf("WeekStart(%s) = %s, want %s", today, got, tc.wantWeekStart)
}
if got := PeriodKey(today); got != tc.wantPeriodKey {
t.Errorf("PeriodKey(%s) = %s, want %s", today, got, tc.wantPeriodKey)
}
}
}
func TestWeekStartEveryWeekday(t *testing.T) {
for _, d := range []string{
"2026-10-05", "2026-10-06", "2026-10-07", "2026-10-08",
"2026-10-09", "2026-10-10", "2026-10-11",
} {
if got := WeekStart(date(t, d)).String(); got != "2026-10-05" {
t.Errorf("WeekStart(%s) = %s, want 2026-10-05", d, got)
}
}
if got := WeekStart(date(t, "2026-10-12")).String(); got != "2026-10-12" {
t.Errorf("WeekStart(2026-10-12) = %s, want 2026-10-12", got)
}
}
func TestWeekDays(t *testing.T) {
days, err := WeekDays(date(t, "2026-09-28"))
if err != nil {
t.Fatal(err)
}
want := []string{
"2026-09-28", "2026-09-29", "2026-09-30", "2026-10-01",
"2026-10-02", "2026-10-03", "2026-10-04",
}
for i, d := range days {
if d.String() != want[i] {
t.Errorf("WeekDays[%d] = %s, want %s", i, d, want[i])
}
}
}
func TestWeekDaysAcrossDSTChange(t *testing.T) {
// Chicago falls back on Sunday 2026-11-01 and Auckland springs forward on
// Sunday 2026-09-27; neither shifts the dates of those weeks.
for _, tc := range []struct{ start, last string }{
{"2026-10-26", "2026-11-01"},
{"2026-09-21", "2026-09-27"},
{"2026-03-02", "2026-03-08"},
} {
days, err := WeekDays(date(t, tc.start))
if err != nil {
t.Fatal(err)
}
if days[0].String() != tc.start || days[6].String() != tc.last {
t.Errorf("WeekDays(%s) = %s … %s, want %s … %s", tc.start, days[0], days[6], tc.start, tc.last)
}
}
}
func TestISOWeekYearBoundary(t *testing.T) {
for _, tc := range []struct{ day, weekStart, key string }{
{"2026-12-27", "2026-12-21", "2026-W52"},
{"2026-12-28", "2026-12-28", "2026-W53"},
{"2026-12-31", "2026-12-28", "2026-W53"},
{"2027-01-01", "2026-12-28", "2026-W53"},
{"2027-01-03", "2026-12-28", "2026-W53"},
{"2027-01-04", "2027-01-04", "2027-W01"},
{"2027-01-10", "2027-01-04", "2027-W01"},
{"2026-01-01", "2025-12-29", "2026-W01"},
{"2026-10-07", "2026-10-05", "2026-W41"},
} {
d := date(t, tc.day)
if got := WeekStart(d).String(); got != tc.weekStart {
t.Errorf("WeekStart(%s) = %s, want %s", tc.day, got, tc.weekStart)
}
if got := PeriodKey(d); got != tc.key {
t.Errorf("PeriodKey(%s) = %s, want %s", tc.day, got, tc.key)
}
}
days, err := WeekDays(date(t, "2026-12-28"))
if err != nil {
t.Fatal(err)
}
if days[6].String() != "2027-01-03" {
t.Errorf("W53 Sunday = %s, want 2027-01-03", days[6])
}
}
func TestWeekLabel(t *testing.T) {
for _, tc := range []struct{ start, want string }{
{"2026-10-05", "Week 41 · Oct 5 – 11"},
{"2026-09-28", "Week 40 · Sep 28 – Oct 4"},
{"2026-12-28", "Week 53 · Dec 28 – Jan 3"},
{"2027-01-04", "Week 1 · Jan 4 – 10"},
} {
got, err := WeekLabel(date(t, tc.start))
if err != nil {
t.Fatal(err)
}
if got != tc.want {
t.Errorf("WeekLabel(%s) = %q, want %q", tc.start, got, tc.want)
}
}
}
func TestWeekStartRequired(t *testing.T) {
for _, d := range []store.LocalDate{date(t, "2026-10-07"), date(t, "2026-10-11"), {}} {
if _, err := WeekDays(d); err == nil {
t.Errorf("WeekDays(%s) succeeded, want error", d)
}
if _, err := WeekLabel(d); err == nil {
t.Errorf("WeekLabel(%s) succeeded, want error", d)
}
}
}
func TestZeroDatePanics(t *testing.T) {
for name, fn := range map[string]func(){
"WeekStart": func() { WeekStart(store.LocalDate{}) },
"PeriodKey": func() { PeriodKey(store.LocalDate{}) },
} {
func() {
defer func() {
if recover() == nil {
t.Errorf("%s(zero) did not panic", name)
}
}()
fn()
}()
}
}
func TestLocalTime(t *testing.T) {
for _, tc := range []struct {
zone, day, hhmm string
want string // UTC instant
wantWall string // local wall clock of the result
}{
// Ordinary days.
{"America/Chicago", "2026-10-05", "18:30", "2026-10-05T23:30:00Z", "2026-10-05 18:30 CDT"},
{"America/Chicago", "2026-12-28", "00:00", "2026-12-28T06:00:00Z", "2026-12-28 00:00 CST"},
{"Pacific/Auckland", "2026-10-05", "18:30", "2026-10-05T05:30:00Z", "2026-10-05 18:30 NZDT"},
{"Pacific/Auckland", "2026-07-01", "23:59", "2026-07-01T11:59:00Z", "2026-07-01 23:59 NZST"},
// Chicago spring forward, Sunday 2026-03-08: 02:00 CST → 03:00 CDT.
{"America/Chicago", "2026-03-08", "01:59", "2026-03-08T07:59:00Z", "2026-03-08 01:59 CST"},
{"America/Chicago", "2026-03-08", "02:00", "2026-03-08T08:00:00Z", "2026-03-08 03:00 CDT"},
{"America/Chicago", "2026-03-08", "02:30", "2026-03-08T08:30:00Z", "2026-03-08 03:30 CDT"},
{"America/Chicago", "2026-03-08", "03:00", "2026-03-08T08:00:00Z", "2026-03-08 03:00 CDT"},
{"America/Chicago", "2026-03-08", "03:30", "2026-03-08T08:30:00Z", "2026-03-08 03:30 CDT"},
// Chicago fall back, Sunday 2026-11-01: 02:00 CDT → 01:00 CST, so
// 01:00–01:59 happens twice; the first (CDT) occurrence is chosen.
{"America/Chicago", "2026-11-01", "00:59", "2026-11-01T05:59:00Z", "2026-11-01 00:59 CDT"},
{"America/Chicago", "2026-11-01", "01:00", "2026-11-01T06:00:00Z", "2026-11-01 01:00 CDT"},
{"America/Chicago", "2026-11-01", "01:30", "2026-11-01T06:30:00Z", "2026-11-01 01:30 CDT"},
{"America/Chicago", "2026-11-01", "02:00", "2026-11-01T08:00:00Z", "2026-11-01 02:00 CST"},
// Auckland spring forward, Sunday 2026-09-27: 02:00 NZST → 03:00 NZDT.
{"Pacific/Auckland", "2026-09-27", "01:59", "2026-09-26T13:59:00Z", "2026-09-27 01:59 NZST"},
{"Pacific/Auckland", "2026-09-27", "02:30", "2026-09-26T14:30:00Z", "2026-09-27 03:30 NZDT"},
{"Pacific/Auckland", "2026-09-27", "03:00", "2026-09-26T14:00:00Z", "2026-09-27 03:00 NZDT"},
// Auckland fall back, Sunday 2026-04-05: 03:00 NZDT → 02:00 NZST, so
// 02:00–02:59 happens twice; the first (NZDT) occurrence is chosen.
{"Pacific/Auckland", "2026-04-05", "01:59", "2026-04-04T12:59:00Z", "2026-04-05 01:59 NZDT"},
{"Pacific/Auckland", "2026-04-05", "02:30", "2026-04-04T13:30:00Z", "2026-04-05 02:30 NZDT"},
{"Pacific/Auckland", "2026-04-05", "03:00", "2026-04-04T15:00:00Z", "2026-04-05 03:00 NZST"},
} {
loc := loadZone(t, tc.zone)
got, err := LocalTime(loc, date(t, tc.day), tc.hhmm)
if err != nil {
t.Fatalf("LocalTime(%s, %s, %s): %v", tc.zone, tc.day, tc.hhmm, err)
}
if !got.Equal(utc(t, tc.want)) {
t.Errorf("LocalTime(%s, %s, %s) = %s, want %s", tc.zone, tc.day, tc.hhmm, got.UTC().Format(time.RFC3339), tc.want)
}
if wall := got.Format("2006-01-02 15:04 MST"); wall != tc.wantWall {
t.Errorf("LocalTime(%s, %s, %s) wall = %s, want %s", tc.zone, tc.day, tc.hhmm, wall, tc.wantWall)
}
if got.Location() != loc {
t.Errorf("LocalTime(%s, …) location = %s, want %s", tc.zone, got.Location(), loc)
}
}
}
func TestLocalTimeDeterministic(t *testing.T) {
// The same ambiguous or skipped input always resolves to the same instant.
chicago := loadZone(t, "America/Chicago")
for _, tc := range []struct{ day, hhmm string }{{"2026-03-08", "02:30"}, {"2026-11-01", "01:30"}} {
first, err := LocalTime(chicago, date(t, tc.day), tc.hhmm)
if err != nil {
t.Fatal(err)
}
for range 100 {
again, err := LocalTime(chicago, date(t, tc.day), tc.hhmm)
if err != nil {
t.Fatal(err)
}
if !again.Equal(first) {
t.Fatalf("LocalTime(%s, %s) = %s then %s", tc.day, tc.hhmm, first, again)
}
}
}
}
func TestLocalTimeRejectsInvalid(t *testing.T) {
chicago := loadZone(t, "America/Chicago")
d := date(t, "2026-10-05")
for _, hhmm := range []string{
"", "9:30", "09:3", "24:00", "23:60", "09:30:00", "0930", " 09:30", "09:30 ", "9am", "-1:00",
} {
if got, err := LocalTime(chicago, d, hhmm); err == nil {
t.Errorf("LocalTime(%q) = %s, want error", hhmm, got)
} else if !strings.Contains(err.Error(), "HH:MM") {
t.Errorf("LocalTime(%q) error %q does not name the HH:MM form", hhmm, err)
}
}
if _, err := LocalTime(chicago, store.LocalDate{}, "09:30"); err == nil {
t.Error("LocalTime(zero date) succeeded")
}
}
+81
View File
@@ -0,0 +1,81 @@
// Package secretbox encrypts secrets at rest with AES-256-GCM. It is the only
// owner of secret encryption: every _ct column holds a token produced by
// Box.Encrypt, laid out as nonce ‖ ciphertext.
//
// Errors from this package never contain plaintext, ciphertext or key
// material.
package secretbox
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
)
// KeySize is the decoded key length in bytes (AES-256).
const KeySize = 32
var (
// ErrKeyEncoding means the key is not valid standard base64.
ErrKeyEncoding = errors.New("secretbox: key is not valid standard base64")
// ErrKeyLength means the decoded key is not exactly KeySize bytes.
ErrKeyLength = errors.New("secretbox: key must decode to exactly 32 bytes")
// ErrMalformed means a token is too short to hold a nonce and tag.
ErrMalformed = errors.New("secretbox: token is too short")
// ErrDecrypt means a token failed authentication: it was corrupted or
// sealed under a different key.
ErrDecrypt = errors.New("secretbox: token failed authentication")
)
// Box seals and opens secrets under one key. It is safe for concurrent use.
type Box struct {
aead cipher.AEAD
}
// New returns a Box for encodedKey, the standard-base64 encoding of a
// KeySize-byte key.
func New(encodedKey string) (*Box, error) {
key, err := base64.StdEncoding.Strict().DecodeString(encodedKey)
if err != nil {
// The decoder's error is not wrapped: it describes the key's content.
return nil, ErrKeyEncoding
}
if len(key) != KeySize {
return nil, fmt.Errorf("%w, got %d", ErrKeyLength, len(key))
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("secretbox: %w", err)
}
aead, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("secretbox: %w", err)
}
return &Box{aead: aead}, nil
}
// Encrypt seals plaintext under a fresh random nonce and returns
// nonce ‖ ciphertext.
func (b *Box) Encrypt(plaintext []byte) ([]byte, error) {
nonce := make([]byte, b.aead.NonceSize(), b.aead.NonceSize()+len(plaintext)+b.aead.Overhead())
if _, err := rand.Read(nonce); err != nil {
return nil, fmt.Errorf("secretbox: generate nonce: %w", err)
}
return b.aead.Seal(nonce, nonce, plaintext, nil), nil
}
// Decrypt authenticates and opens a token produced by Encrypt.
func (b *Box) Decrypt(token []byte) ([]byte, error) {
n := b.aead.NonceSize()
if len(token) < n+b.aead.Overhead() {
return nil, ErrMalformed
}
plaintext, err := b.aead.Open(nil, token[:n], token[n:], nil)
if err != nil {
return nil, ErrDecrypt
}
return plaintext, nil
}
+200
View File
@@ -0,0 +1,200 @@
package secretbox
import (
"bytes"
"crypto/rand"
"encoding/base64"
"errors"
"strings"
"testing"
)
// newKey returns a fresh random key and its standard-base64 encoding.
func newKey(t *testing.T) ([]byte, string) {
t.Helper()
key := make([]byte, KeySize)
if _, err := rand.Read(key); err != nil {
t.Fatal(err)
}
return key, base64.StdEncoding.EncodeToString(key)
}
func newBox(t *testing.T) (*Box, []byte, string) {
t.Helper()
key, encoded := newKey(t)
box, err := New(encoded)
if err != nil {
t.Fatal(err)
}
return box, key, encoded
}
// assertNoLeak fails if err's message contains any of the secrets, either
// raw or base64-encoded.
func assertNoLeak(t *testing.T, err error, secrets ...[]byte) {
t.Helper()
msg := err.Error()
for _, s := range secrets {
if len(s) == 0 {
continue
}
for _, form := range []string{string(s), base64.StdEncoding.EncodeToString(s), base64.RawStdEncoding.EncodeToString(s)} {
if strings.Contains(msg, form) {
t.Errorf("error %q leaks secret material %q", msg, form)
}
}
}
}
func TestRoundTrip(t *testing.T) {
box, _, _ := newBox(t)
for _, plaintext := range [][]byte{
[]byte("ya29.oauth-refresh-token"),
{},
bytes.Repeat([]byte{0x00, 0xff}, 4096),
} {
token, err := box.Encrypt(plaintext)
if err != nil {
t.Fatal(err)
}
if len(plaintext) > 0 && bytes.Contains(token, plaintext) {
t.Errorf("token contains the plaintext")
}
got, err := box.Decrypt(token)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got, plaintext) {
t.Errorf("Decrypt = %q, want %q", got, plaintext)
}
}
}
func TestTokenLayout(t *testing.T) {
box, _, _ := newBox(t)
plaintext := []byte("secret")
token, err := box.Encrypt(plaintext)
if err != nil {
t.Fatal(err)
}
// nonce (12) ‖ ciphertext (len(plaintext)) ‖ GCM tag (16).
if want := 12 + len(plaintext) + 16; len(token) != want {
t.Errorf("token length = %d, want %d", len(token), want)
}
}
func TestEncryptUsesFreshNonce(t *testing.T) {
box, _, _ := newBox(t)
plaintext := []byte("same plaintext")
a, err := box.Encrypt(plaintext)
if err != nil {
t.Fatal(err)
}
b, err := box.Encrypt(plaintext)
if err != nil {
t.Fatal(err)
}
if bytes.Equal(a, b) {
t.Fatal("two encryptions of the same plaintext are identical")
}
if bytes.Equal(a[:12], b[:12]) {
t.Error("two encryptions reused the same nonce")
}
}
func TestDecryptRejectsFlippedByte(t *testing.T) {
box, key, encoded := newBox(t)
plaintext := []byte("do-not-leak-me")
token, err := box.Encrypt(plaintext)
if err != nil {
t.Fatal(err)
}
// Flip one bit in every position: nonce, ciphertext and tag.
for i := range token {
tampered := bytes.Clone(token)
tampered[i] ^= 0x01
got, err := box.Decrypt(tampered)
if !errors.Is(err, ErrDecrypt) {
t.Fatalf("byte %d flipped: Decrypt = %q, %v; want ErrDecrypt", i, got, err)
}
if got != nil {
t.Errorf("byte %d flipped: Decrypt returned plaintext alongside the error", i)
}
assertNoLeak(t, err, plaintext, key, []byte(encoded), tampered)
}
}
func TestDecryptRejectsWrongKey(t *testing.T) {
box, key, encoded := newBox(t)
other, otherKey, otherEncoded := newBox(t)
plaintext := []byte("do-not-leak-me")
token, err := box.Encrypt(plaintext)
if err != nil {
t.Fatal(err)
}
got, err := other.Decrypt(token)
if !errors.Is(err, ErrDecrypt) {
t.Fatalf("Decrypt with wrong key = %q, %v; want ErrDecrypt", got, err)
}
assertNoLeak(t, err, plaintext, key, []byte(encoded), otherKey, []byte(otherEncoded), token)
}
func TestDecryptRejectsShortToken(t *testing.T) {
box, _, _ := newBox(t)
for _, n := range []int{0, 1, 12, 12 + 15} {
token := bytes.Repeat([]byte{0xab}, n)
if _, err := box.Decrypt(token); !errors.Is(err, ErrMalformed) {
t.Errorf("Decrypt(%d bytes) error = %v, want ErrMalformed", n, err)
}
}
}
func TestNewRejectsBadKey(t *testing.T) {
key, _ := newKey(t)
tests := []struct {
name string
encoded string
want error
}{
{"empty", "", ErrKeyLength},
{"not base64", "not*base64!" + strings.Repeat("A", 33), ErrKeyEncoding},
{"url-safe alphabet", strings.Repeat("_-", 22), ErrKeyEncoding},
{"unpadded", base64.RawStdEncoding.EncodeToString(key), ErrKeyEncoding},
{"raw key bytes", string(key), ErrKeyEncoding},
{"too short", base64.StdEncoding.EncodeToString(key[:31]), ErrKeyLength},
{"too long", base64.StdEncoding.EncodeToString(append(bytes.Clone(key), 0x00)), ErrKeyLength},
{"AES-128 length", base64.StdEncoding.EncodeToString(key[:16]), ErrKeyLength},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
box, err := New(tt.encoded)
if !errors.Is(err, tt.want) {
t.Fatalf("New error = %v, want %v", err, tt.want)
}
if box != nil {
t.Error("New returned a Box alongside the error")
}
assertNoLeak(t, err, key, []byte(tt.encoded))
})
}
}
func TestNewAcceptsBase64Key(t *testing.T) {
key, encoded := newKey(t)
box, err := New(encoded)
if err != nil {
t.Fatal(err)
}
// A Box built from the same key opens tokens sealed by the first.
token, err := box.Encrypt([]byte("x"))
if err != nil {
t.Fatal(err)
}
again, err := New(base64.StdEncoding.EncodeToString(key))
if err != nil {
t.Fatal(err)
}
if _, err := again.Decrypt(token); err != nil {
t.Errorf("same key failed to decrypt: %v", err)
}
}
-32
View File
@@ -1,32 +0,0 @@
// Package secureurl holds the one transport rule for URLs that carry
// credentials or session cookies: HTTPS, or plain HTTP to a loopback host for
// local development and tests.
package secureurl
import (
"errors"
"fmt"
"net/url"
"strings"
)
// loopbackHosts are the only hosts plain HTTP is allowed for, compared
// case-insensitively. url.URL's Hostname strips the brackets from "[::1]".
var loopbackHosts = map[string]bool{"localhost": true, "127.0.0.1": true, "::1": true}
// Check reports an error unless u is an absolute https:// URL, or an http://
// URL whose host is localhost, 127.0.0.1 or ::1.
func Check(u *url.URL) error {
switch {
case u.Hostname() == "":
return errors.New("URL has no host")
case u.Scheme == "https":
return nil
case u.Scheme == "http" && loopbackHosts[strings.ToLower(u.Hostname())]:
return nil
case u.Scheme == "http":
return errors.New("plain http:// is allowed only for localhost, 127.0.0.1 or [::1]; use https://")
default:
return fmt.Errorf("unsupported scheme %q (want https://)", u.Scheme)
}
}
-40
View File
@@ -1,40 +0,0 @@
package secureurl
import (
"net/url"
"testing"
)
func TestCheck(t *testing.T) {
t.Parallel()
tests := []struct {
raw string
ok bool
}{
{"https://meals.example.com", true},
{"https://auth.example.com/application/o/mealquest/", true},
{"https://192.0.2.10:8443", true},
{"http://localhost:8080", true},
{"http://LOCALHOST:8080", true},
{"http://127.0.0.1:8080/path", true},
{"http://[::1]:8080", true},
{"http://[::1]", true},
{"http://meals.example.com", false},
{"http://192.0.2.10", false},
{"http://127.0.0.2", false},
{"http://localhost.example.com", false},
{"http://0.0.0.0:8080", false},
{"ftp://localhost", false},
{"https://", false},
{"meals.example.com", false},
}
for _, tc := range tests {
u, err := url.Parse(tc.raw)
if err != nil {
t.Fatal(err)
}
if err := Check(u); (err == nil) != tc.ok {
t.Errorf("Check(%q) error = %v, want ok=%v", tc.raw, err, tc.ok)
}
}
}
+4 -23
View File
@@ -16,16 +16,9 @@ import (
// SPA's index.html.
var reservedPrefixes = []string{"/api", "/auth", "/media", "/health"}
// RouteMounter registers its own routes on the router.
type RouteMounter interface {
Mount(r chi.Router)
}
// NewRouter returns the HTTP handler for the service. version is reported by
// GET /health; auth mounts the /auth routes; sessions guards /api/v1 and
// /media; assets is the web build served for every other GET. /health, /auth
// and the web build are public.
func NewRouter(logger *slog.Logger, version string, auth RouteMounter, sessions SessionLookup, assets fs.FS) (http.Handler, error) {
// GET /health; assets is the web build served for every other GET.
func NewRouter(logger *slog.Logger, version string, assets fs.FS) (http.Handler, error) {
notFound := apiErrorHandler(logger, http.StatusNotFound, "not_found", "not found")
methodNotAllowed := apiErrorHandler(logger, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
@@ -39,24 +32,12 @@ func NewRouter(logger *slog.Logger, version string, auth RouteMounter, sessions
r.NotFound(notFound)
r.Get("/health", health(logger, version))
auth.Mount(r)
// The session middleware wraps the whole /api/v1 and /media subrouters
// rather than being added with Use: chi skips a subrouter's middleware
// when it has no routes, which would leave it unguarded.
signedIn := requireSession(logger, sessions)
apiV1, media := chi.NewRouter(), chi.NewRouter()
for _, sub := range []*chi.Mux{apiV1, media} {
sub.MethodNotAllowed(methodNotAllowed)
sub.NotFound(notFound)
}
r.Route("/api", func(r chi.Router) {
r.MethodNotAllowed(methodNotAllowed)
r.NotFound(notFound)
r.Mount("/v1", signedIn(apiV1))
})
r.Mount("/media", signedIn(media))
// Catch-all for the PWA. Unknown /auth paths reach the frontend handler,
// which passes them to notFound rather than the SPA fallback.
// Catch-all for the PWA. /auth and /media have no routes yet; the frontend
// handler passes them to notFound rather than the SPA fallback.
r.Method(http.MethodGet, "/*", web)
r.Method(http.MethodHead, "/*", web)
return r, nil
+3 -36
View File
@@ -10,8 +10,6 @@ import (
"strings"
"testing"
"testing/fstest"
"github.com/go-chi/chi/v5"
)
const (
@@ -27,39 +25,13 @@ func newTestRouter(t *testing.T, version string) http.Handler {
"index.html": {Data: []byte(fixtureIndex)},
"assets/index-AbC123.js": {Data: []byte(fixtureAsset)},
}
r, err := NewRouter(logger, version, stubAuth{}, stubSessions{}, assets)
r, err := NewRouter(logger, version, assets)
if err != nil {
t.Fatal(err)
}
return r
}
// stubAuth mounts a fixed GET /auth/login in place of the real auth flow.
type stubAuth struct{}
func (stubAuth) Mount(r chi.Router) {
r.Get("/auth/login", func(w http.ResponseWriter, _ *http.Request) {
io.WriteString(w, "stub login")
})
}
func TestAuthRoutesMounted(t *testing.T) {
t.Parallel()
router := newTestRouter(t, "dev")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/auth/login", nil))
if rec.Code != http.StatusOK || rec.Body.String() != "stub login" {
t.Errorf("GET /auth/login = %d %q, want the mounted handler", rec.Code, rec.Body.String())
}
rec = httptest.NewRecorder()
router.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/auth/login", nil))
if rec.Code != http.StatusMethodNotAllowed {
t.Errorf("POST /auth/login status = %d, want 405", rec.Code)
}
}
func TestHealth(t *testing.T) {
t.Parallel()
@@ -126,10 +98,8 @@ func TestReservedPathsNotFound(t *testing.T) {
}{
{http.MethodGet, "/api"},
{http.MethodGet, "/api/unknown"},
{http.MethodGet, "/api/v1/unknown"},
{http.MethodPost, "/api/v1/unknown"},
{http.MethodPost, "/api/unknown"},
{http.MethodGet, "/auth/unknown"},
{http.MethodGet, "/auth/callback"},
{http.MethodGet, "/media/photo.jpg"},
{http.MethodGet, "/health/deep"},
{http.MethodGet, "/auth/../quests/42"},
@@ -139,11 +109,8 @@ func TestReservedPathsNotFound(t *testing.T) {
t.Run(tt.method+" "+tt.path, func(t *testing.T) {
t.Parallel()
// Signed in, so /api/v1 and /media reach their routers.
req := httptest.NewRequest(tt.method, tt.path, nil)
req.AddCookie(validSessionCookie)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
router.ServeHTTP(rec, httptest.NewRequest(tt.method, tt.path, nil))
if rec.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404", rec.Code)
-37
View File
@@ -1,37 +0,0 @@
package server
import (
"errors"
"log/slog"
"net/http"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
)
// SessionLookup finds the live session a request carries; it returns
// auth.ErrNoSession when there is none. *auth.SessionManager implements it.
type SessionLookup interface {
SessionFor(r *http.Request) (auth.Session, error)
}
// requireSession is the RequireSession middleware. It passes requests with a
// live session to next with the session's actor in the context (read it with
// auth.ActorFrom); others get 401 unauthenticated.
func requireSession(logger *slog.Logger, sessions SessionLookup) func(http.Handler) http.Handler {
unauthenticated := apiErrorHandler(logger, http.StatusUnauthorized, "unauthenticated", "sign in required")
internal := apiErrorHandler(logger, http.StatusInternalServerError, "internal", "internal error")
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
s, err := sessions.SessionFor(r)
switch {
case errors.Is(err, auth.ErrNoSession):
unauthenticated(w, r)
case err != nil:
logger.ErrorContext(r.Context(), "look up session", "error", err)
internal(w, r)
default:
next.ServeHTTP(w, r.WithContext(auth.WithActor(r.Context(), s.Actor())))
}
})
}
}
-151
View File
@@ -1,151 +0,0 @@
package server
import (
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"slices"
"testing"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
)
const (
unauthenticatedJSON = `{"error":{"code":"unauthenticated","message":"sign in required","retryable":false}}`
internalJSON = `{"error":{"code":"internal","message":"internal error","retryable":false}}`
)
var (
validSessionCookie = &http.Cookie{Name: auth.SessionCookieName, Value: "valid"}
brokenSessionCookie = &http.Cookie{Name: auth.SessionCookieName, Value: "broken"}
testSession = auth.Session{
Subject: "user-123",
Email: "cook@example.com",
Name: "Home Cook",
Groups: []string{"mealquest-admin"},
}
)
// stubSessions knows one valid session cookie; the broken cookie fails the
// lookup as a database error would.
type stubSessions struct{}
func (stubSessions) SessionFor(r *http.Request) (auth.Session, error) {
c, err := r.Cookie(auth.SessionCookieName)
switch {
case err != nil:
return auth.Session{}, auth.ErrNoSession
case c.Value == validSessionCookie.Value:
return testSession, nil
case c.Value == brokenSessionCookie.Value:
return auth.Session{}, errors.New("database down")
default:
return auth.Session{}, auth.ErrNoSession
}
}
func TestProtectedPathsRequireSession(t *testing.T) {
t.Parallel()
router := newTestRouter(t, "dev")
for _, tc := range []struct {
method, path string
cookie *http.Cookie
}{
{http.MethodGet, "/api/v1/settings/household", nil},
{http.MethodPatch, "/api/v1/settings/household", nil},
{http.MethodGet, "/api/v1", nil},
{http.MethodGet, "/media/photo.jpg", nil},
{http.MethodGet, "/api/v1/settings/household", &http.Cookie{Name: auth.SessionCookieName, Value: "unknown"}},
} {
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(tc.method, tc.path, nil)
if tc.cookie != nil {
req.AddCookie(tc.cookie)
}
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
t.Errorf("Content-Type = %q, want application/json", ct)
}
if got := rec.Body.String(); got != unauthenticatedJSON {
t.Errorf("body = %s, want %s", got, unauthenticatedJSON)
}
})
}
}
func TestPublicPathsNeedNoSession(t *testing.T) {
t.Parallel()
router := newTestRouter(t, "dev")
for _, path := range []string{"/health", "/auth/login", "/", "/assets/index-AbC123.js", "/quests/42"} {
t.Run(path, func(t *testing.T) {
t.Parallel()
rec := httptest.NewRecorder()
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
if rec.Code != http.StatusOK {
t.Errorf("GET %s without a cookie = %d, want 200", path, rec.Code)
}
})
}
}
func TestSessionLookupFailure(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(http.MethodGet, "/api/v1/settings/household", nil)
req.AddCookie(brokenSessionCookie)
rec := httptest.NewRecorder()
newTestRouter(t, "dev").ServeHTTP(rec, req)
if rec.Code != http.StatusInternalServerError || rec.Body.String() != internalJSON {
t.Errorf("response = %d %s, want 500 %s", rec.Code, rec.Body.String(), internalJSON)
}
}
func TestRequireSessionAttachesActor(t *testing.T) {
t.Parallel()
var got auth.Actor
var ok bool
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got, ok = auth.ActorFrom(r.Context())
w.WriteHeader(http.StatusNoContent)
})
h := requireSession(slog.New(slog.NewTextHandler(io.Discard, nil)), stubSessions{})(next)
req := httptest.NewRequest(http.MethodGet, "/api/v1/settings/household", nil)
req.AddCookie(validSessionCookie)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want the handler's 204", rec.Code)
}
if !ok || got.Subject != "user-123" || got.Email != "cook@example.com" || got.Name != "Home Cook" ||
!slices.Equal(got.Groups, []string{"mealquest-admin"}) {
t.Errorf("actor = %+v (ok=%v), want the session's household member", got, ok)
}
}
func TestRequireSessionStopsUnauthenticated(t *testing.T) {
t.Parallel()
called := false
next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true })
h := requireSession(slog.New(slog.NewTextHandler(io.Discard, nil)), stubSessions{})(next)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/settings/household", nil))
if called || rec.Code != http.StatusUnauthorized {
t.Errorf("handler called = %v, status = %d; want not called and 401", called, rec.Code)
}
}
+16 -1
View File
@@ -86,6 +86,17 @@ func ParseLocalDate(s string) (LocalDate, error) {
return LocalDate{t.Year(), t.Month(), t.Day()}, nil
}
// LocalDateOf returns the calendar date of t in t's own location.
func LocalDateOf(t time.Time) LocalDate {
y, m, d := t.Date()
return LocalDate{y, m, d}
}
// Date returns the year, month and day of d.
func (d LocalDate) Date() (year int, month time.Month, day int) {
return d.year, d.month, d.day
}
// String returns the YYYY-MM-DD form.
func (d LocalDate) String() string {
return fmt.Sprintf("%04d-%02d-%02d", d.year, int(d.month), d.day)
@@ -96,11 +107,15 @@ func (d LocalDate) IsZero() bool {
return d == LocalDate{}
}
// Value implements driver.Valuer. The zero LocalDate is an error.
// Value implements driver.Valuer. The zero LocalDate is an error, as is a
// year outside 0000–9999, which the four-digit stored form cannot represent.
func (d LocalDate) Value() (driver.Value, error) {
if d.IsZero() {
return nil, errors.New("store: zero LocalDate")
}
if d.year < 0 || d.year > 9999 {
return nil, fmt.Errorf("store: LocalDate year %d outside 0000-9999", d.year)
}
return d.String(), nil
}
+25
View File
@@ -102,6 +102,25 @@ func TestLocalDateRoundTrip(t *testing.T) {
}
}
func TestLocalDateOf(t *testing.T) {
auckland, err := time.LoadLocation("Pacific/Auckland")
if err != nil {
t.Fatal(err)
}
// 2026-10-03T20:00Z is already 2026-10-04 in Auckland (UTC+13).
instant := time.Date(2026, time.October, 3, 20, 0, 0, 0, time.UTC)
if got := LocalDateOf(instant).String(); got != "2026-10-03" {
t.Errorf("LocalDateOf(UTC) = %s, want 2026-10-03", got)
}
d := LocalDateOf(instant.In(auckland))
if got := d.String(); got != "2026-10-04" {
t.Errorf("LocalDateOf(Auckland) = %s, want 2026-10-04", got)
}
if y, m, day := d.Date(); y != 2026 || m != time.October || day != 4 {
t.Errorf("Date() = %d-%d-%d, want 2026-10-4", y, m, day)
}
}
func TestLocalDateRejectsInvalid(t *testing.T) {
for _, str := range []string{
"", "2026-02-30", "2026-02-29", "2026-13-01", "2026-00-10", "2026-10-00",
@@ -119,6 +138,12 @@ func TestLocalDateRejectsInvalid(t *testing.T) {
if _, err := (LocalDate{}).Value(); err == nil {
t.Error("zero LocalDate Value succeeded")
}
for _, y := range []int{-1, 10000} {
d := LocalDateOf(time.Date(y, time.January, 1, 0, 0, 0, 0, time.UTC))
if _, err := d.Value(); err == nil {
t.Errorf("LocalDate year %d Value succeeded", y)
}
}
var d LocalDate
if err := d.Scan(nil); err == nil {
t.Error("Scan(nil) succeeded")