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
20 changed files with 898 additions and 1637 deletions
+1 -9
View File
@@ -30,12 +30,6 @@ 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
make dev
```
@@ -43,9 +37,7 @@ make dev
(which proxies `/api`, `/auth` and `/media` to `:8080`). Open
http://localhost:5173. Configuration is environment-only and fails fast:
every variable above is required, 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]`.
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 -21
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,18 +56,7 @@ func run(cfg config.Config, logger *slog.Logger) error {
}
}()
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()),
}, sessionsPending{}, logger)
if err != nil {
return err
}
router, err := server.NewRouter(logger, version, authFlow, frontend.Assets())
router, err := server.NewRouter(logger, version, frontend.Assets())
if err != nil {
return err
}
@@ -98,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
@@ -115,14 +118,6 @@ func openStore(ctx context.Context, dataDir string) (*store.Store, error) {
return db, nil
}
// sessionsPending refuses every login until the session layer (Feature 03
// task 2) replaces it.
type sessionsPending struct{}
func (sessionsPending) StartSession(http.ResponseWriter, *http.Request, auth.Claims) error {
return errors.New("session layer not implemented")
}
// logListening logs the startup line. Only the public URL's origin is logged
// so no credential-bearing URL component can reach the log.
func logListening(logger *slog.Logger, cfg config.Config) {
-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=
-299
View File
@@ -1,299 +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"
"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"
)
// 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"`
}
// SessionStarter is the session layer's entry point. StartSession is called
// once per successful login with claims from a verified ID token; it must set
// the session cookie on w. A returned error aborts the login with 500.
type SessionStarter interface {
StartSession(w http.ResponseWriter, r *http.Request, claims Claims) 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
}
// Auth serves the login and callback routes.
type Auth struct {
oauth oauth2.Config
verifier *oidc.IDTokenVerifier
client *http.Client
cookie loginCookie
sessions SessionStarter
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 SessionStarter, 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 starter 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"`
}
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},
} {
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},
}),
client: client,
cookie: newLoginCookie(cfg.SecretKey),
sessions: sessions,
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 and GET CallbackPath on r.
func (a *Auth) Mount(r chi.Router) {
r.Get(LoginPath, a.login)
r.Get(CallbackPath, a.callback)
}
// 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.
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 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)
}
// verify exchanges code for tokens and returns the verified ID token claims.
// The verifier enforces RS256, issuer, audience and expiry; nonce is checked
// here.
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")
}
return claims, nil
}
// 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")
}
}
-901
View File
@@ -1,901 +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"}
// 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
}
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)
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",
"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,
})
}
// 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 SessionStarter that records the claims it
// was handed.
type memorySessions struct {
mu sync.Mutex
claims []Claims
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) 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"),
}, 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"),
}, &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"),
}, &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"),
}, &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"},
}
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"),
}, &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"),
}, 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
}
+4 -52
View File
@@ -11,8 +11,6 @@ import (
"strconv"
"strings"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secureurl"
)
// Environment variable names.
@@ -22,11 +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"
)
// Config is the validated service configuration.
@@ -36,13 +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
}
// PublicOrigin returns the scheme and host of PublicURL, the only parts of it
@@ -61,15 +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),
}
if len(r.problems) > 0 {
return Config{}, &Error{Problems: r.problems}
@@ -133,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 {
@@ -197,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 {
+2 -100
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,54 +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: "several invalid values are all named",
env: with(map[string]string{
@@ -288,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)
}
})
@@ -303,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 {
@@ -339,18 +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)
}
}
func TestSecretRedacted(t *testing.T) {
@@ -358,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 -12
View File
@@ -16,15 +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; assets is the web build served
// for every other GET.
func NewRouter(logger *slog.Logger, version string, auth RouteMounter, 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")
@@ -38,14 +32,12 @@ func NewRouter(logger *slog.Logger, version string, auth RouteMounter, assets fs
r.NotFound(notFound)
r.Get("/health", health(logger, version))
auth.Mount(r)
r.Route("/api", func(r chi.Router) {
r.MethodNotAllowed(methodNotAllowed)
r.NotFound(notFound)
})
// Catch-all for the PWA. Unknown /auth paths and /media (no routes yet)
// 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
+2 -30
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{}, 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()
@@ -127,7 +99,7 @@ func TestReservedPathsNotFound(t *testing.T) {
{http.MethodGet, "/api"},
{http.MethodGet, "/api/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"},
+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")