The callback now starts a session only when the verified groups claim
contains a group from MEALQUEST_OIDC_ALLOWED_GROUPS. When the ID token has
no groups claim, groups come from the userinfo endpoint, whose subject must
match the ID token. A refused sign-in gets a 403 HTML page ("This kitchen is
for the household only") with a sign-out button, and no session. The check
runs on every login, so an account removed from the group is refused at its
next one. auth.New requires at least one allowed group and a discovered
userinfo endpoint that passes the endpoint rule.
The RequireSession middleware wraps the /api/v1 and /media subrouters: no
live session gets 401 {"error":{"code":"unauthenticated",...}}, a lookup
failure 500, and a live session reaches the handler with the actor in the
context. It wraps the subrouters instead of using chi's Use because chi
skips middleware on a subrouter with no routes. /health, /auth and the web
build stay public.
auth.WithActor and auth.ActorFrom are the one way to read the signed-in
household member; handlers record Actor.Subject as updated_by.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
133 lines
3.5 KiB
Go
133 lines
3.5 KiB
Go
// Command server runs the MealQuest HTTP service.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"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/server"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
|
)
|
|
|
|
// version is set at build time with -ldflags "-X main.version=<version>".
|
|
var version = "dev"
|
|
|
|
func main() {
|
|
cfg, err := config.Load()
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
|
if err := run(cfg, logger); err != nil {
|
|
logger.Error("server stopped", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func run(cfg config.Config, logger *slog.Logger) error {
|
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
|
defer stop()
|
|
|
|
db, err := openStore(ctx, cfg.DataDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() {
|
|
if err := db.Close(); err != nil {
|
|
logger.Error("close database", "error", err)
|
|
}
|
|
}()
|
|
|
|
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())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
srv := &http.Server{
|
|
Addr: cfg.HTTPAddr,
|
|
Handler: router,
|
|
ReadHeaderTimeout: 10 * time.Second,
|
|
ErrorLog: slog.NewLogLogger(logger.Handler(), slog.LevelError),
|
|
}
|
|
|
|
errc := make(chan error, 1)
|
|
go func() {
|
|
logListening(logger, cfg)
|
|
errc <- srv.ListenAndServe()
|
|
}()
|
|
|
|
select {
|
|
case err := <-errc:
|
|
return err
|
|
case <-ctx.Done():
|
|
}
|
|
|
|
logger.Info("shutting down", "timeout", cfg.ShutdownTimeout.String())
|
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
|
|
defer cancel()
|
|
if err := srv.Shutdown(shutdownCtx); err != nil {
|
|
return fmt.Errorf("shutdown: %w", err)
|
|
}
|
|
if err := <-errc; !errors.Is(err, http.ErrServerClosed) {
|
|
return err
|
|
}
|
|
return 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
|
|
|
|
// openStore opens the database in dataDir and applies bootMigrations. On a
|
|
// migration failure it closes the database and returns the error.
|
|
func openStore(ctx context.Context, dataDir string) (*store.Store, error) {
|
|
db, err := store.Open(ctx, dataDir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := store.Migrate(ctx, db.Writer, bootMigrations); err != nil {
|
|
return nil, errors.Join(err, db.Close())
|
|
}
|
|
return db, nil
|
|
}
|
|
|
|
// 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) {
|
|
logger.Info("listening",
|
|
"addr", cfg.HTTPAddr,
|
|
"public_origin", cfg.PublicOrigin(),
|
|
"data_dir", cfg.DataDir,
|
|
"version", version,
|
|
)
|
|
}
|