Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e73ebe2f2f | ||
|
|
5833a73541 | ||
|
|
36b975ff5b | ||
|
|
b2201c5da1 |
@@ -40,6 +40,10 @@ export MEALQUEST_OIDC_ALLOWED_GROUPS=mealquest-admin
|
||||
# and always MEALQUEST_SESSION_MAX after sign-in (default 4320h).
|
||||
# export MEALQUEST_SESSION_IDLE=720h
|
||||
# export MEALQUEST_SESSION_MAX=4320h
|
||||
# Optional: comma-separated IPs/CIDRs of reverse proxies whose
|
||||
# X-Forwarded-For/-Proto headers are honoured (production: Traefik). Unset
|
||||
# trusts no peer, which is right for local development.
|
||||
# export MEALQUEST_TRUSTED_PROXY=10.60.1.33
|
||||
make dev
|
||||
```
|
||||
|
||||
@@ -47,7 +51,8 @@ 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 except the commented-out session
|
||||
lifetimes (when set they must be positive, with MAX at least IDLE), and
|
||||
lifetimes (when set they must be positive, with MAX at least IDLE) and
|
||||
trusted proxy list (every entry must be an IP or CIDR), 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]`.
|
||||
|
||||
+10
-7
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
"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/forwarded"
|
||||
"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"
|
||||
@@ -31,7 +32,7 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||
logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(os.Stdout, nil)))
|
||||
if err := run(cfg, logger); err != nil {
|
||||
logger.Error("server stopped", "error", err)
|
||||
os.Exit(1)
|
||||
@@ -57,17 +58,18 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
||||
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()),
|
||||
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, frontend.Assets())
|
||||
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, frontend.Assets())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -125,6 +127,7 @@ func logListening(logger *slog.Logger, cfg config.Config) {
|
||||
logger.Info("listening",
|
||||
"addr", cfg.HTTPAddr,
|
||||
"public_origin", cfg.PublicOrigin(),
|
||||
"trusted_proxy", cfg.TrustedProxy,
|
||||
"data_dir", cfg.DataDir,
|
||||
"version", version,
|
||||
)
|
||||
|
||||
@@ -29,6 +29,15 @@ docker compose project "mealquest" — 10.60.1.14
|
||||
One Go service serves the API, the auth flow, media and the PWA. Its SQLite
|
||||
database is replicated by Litestream to the NAS.
|
||||
|
||||
Traefik terminates TLS. The service honours `X-Forwarded-For` and
|
||||
`X-Forwarded-Proto` only from peers listed in `MEALQUEST_TRUSTED_PROXY`
|
||||
(comma-separated IPs/CIDRs; production lists `10.60.1.33`); from any other
|
||||
peer they are ignored. The client is the rightmost `X-Forwarded-For` address
|
||||
that is not a trusted proxy. The resolved client IP is added as `client_ip`
|
||||
to every log line written while handling a request. The OIDC redirect URI
|
||||
and the Secure cookies are fixed by `MEALQUEST_PUBLIC_URL`, never by request
|
||||
headers, so sign-in works whichever peer the request arrives from.
|
||||
|
||||
## Repository layout
|
||||
|
||||
| Path | Purpose |
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// Actor is the signed-in household member a request acts for. Handlers
|
||||
// record Actor.Subject as updated_by on edits.
|
||||
type Actor struct {
|
||||
Subject string
|
||||
Email string
|
||||
Name string
|
||||
Groups []string
|
||||
}
|
||||
|
||||
// Actor returns the household member s belongs to.
|
||||
func (s Session) Actor() Actor {
|
||||
return Actor{Subject: s.Subject, Email: s.Email, Name: s.Name, Groups: slices.Clone(s.Groups)}
|
||||
}
|
||||
|
||||
type sessionKey struct{}
|
||||
|
||||
// WithSession returns a copy of ctx carrying s. The session middleware calls
|
||||
// it for every authenticated request.
|
||||
func WithSession(ctx context.Context, s Session) context.Context {
|
||||
return context.WithValue(ctx, sessionKey{}, s)
|
||||
}
|
||||
|
||||
// SessionFrom returns the session WithSession stored in ctx. ok is false when
|
||||
// ctx has none, which behind the session middleware means a wiring bug.
|
||||
func SessionFrom(ctx context.Context) (s Session, ok bool) {
|
||||
s, ok = ctx.Value(sessionKey{}).(Session)
|
||||
return s, ok
|
||||
}
|
||||
|
||||
// ActorFrom returns the actor of the session WithSession stored in ctx. ok is
|
||||
// false when ctx has none.
|
||||
func ActorFrom(ctx context.Context) (actor Actor, ok bool) {
|
||||
s, ok := SessionFrom(ctx)
|
||||
if !ok {
|
||||
return Actor{}, false
|
||||
}
|
||||
return s.Actor(), true
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestActorContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
if _, ok := ActorFrom(context.Background()); ok {
|
||||
t.Error("ActorFrom found an actor in an empty context")
|
||||
}
|
||||
if _, ok := SessionFrom(context.Background()); ok {
|
||||
t.Error("SessionFrom found a session in an empty context")
|
||||
}
|
||||
s := Session{Subject: "user-123", Email: "cook@example.com", Name: "Home Cook", Groups: []string{"mealquest-admin"}, CSRFToken: "t"}
|
||||
ctx := WithSession(context.Background(), s)
|
||||
gotSession, ok := SessionFrom(ctx)
|
||||
if !ok || gotSession.Subject != s.Subject || gotSession.CSRFToken != s.CSRFToken {
|
||||
t.Errorf("SessionFrom = %+v (ok=%v), want %+v", gotSession, ok, s)
|
||||
}
|
||||
got, ok := ActorFrom(ctx)
|
||||
if !ok || got.Subject != s.Subject || got.Email != s.Email || got.Name != s.Name || !slices.Equal(got.Groups, s.Groups) {
|
||||
t.Errorf("ActorFrom = %+v (ok=%v), want the session's actor", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionActor(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := Session{Subject: "user-123", Email: "cook@example.com", Name: "Home Cook", Groups: []string{"mealquest-admin"}, CSRFToken: "t"}
|
||||
a := s.Actor()
|
||||
if a.Subject != s.Subject || a.Email != s.Email || a.Name != s.Name || !slices.Equal(a.Groups, s.Groups) {
|
||||
t.Errorf("Actor = %+v, want the session's identity", a)
|
||||
}
|
||||
a.Groups[0] = "changed"
|
||||
if s.Groups[0] != "mealquest-admin" {
|
||||
t.Error("Actor shares the session's groups slice")
|
||||
}
|
||||
}
|
||||
+107
-4
@@ -12,6 +12,8 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
@@ -65,15 +67,20 @@ type Config struct {
|
||||
PublicURL *url.URL
|
||||
// SecretKey is the server key the login cookie's signing key derives from.
|
||||
SecretKey []byte
|
||||
// AllowedGroups are the household's groups; a sign-in is refused unless
|
||||
// the account is in at least one of them.
|
||||
AllowedGroups []string
|
||||
}
|
||||
|
||||
// Auth serves the login and callback routes.
|
||||
type Auth struct {
|
||||
oauth oauth2.Config
|
||||
verifier *oidc.IDTokenVerifier
|
||||
provider *oidc.Provider
|
||||
client *http.Client
|
||||
cookie loginCookie
|
||||
sessions Sessions
|
||||
allowed []string
|
||||
// endSessionURL is the provider's end_session_endpoint; logout ends
|
||||
// there so the provider's own session ends too.
|
||||
endSessionURL string
|
||||
@@ -92,6 +99,8 @@ func New(ctx context.Context, cfg Config, sessions Sessions, logger *slog.Logger
|
||||
return nil, errors.New("auth: secret key is required")
|
||||
case sessions == nil:
|
||||
return nil, errors.New("auth: session layer is required")
|
||||
case len(cfg.AllowedGroups) == 0:
|
||||
return nil, errors.New("auth: at least one allowed group is required")
|
||||
}
|
||||
|
||||
if err := checkEndpoint("issuer", cfg.Issuer); err != nil {
|
||||
@@ -99,9 +108,12 @@ func New(ctx context.Context, cfg Config, sessions Sessions, logger *slog.Logger
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: providerTimeout, CheckRedirect: checkRedirect}
|
||||
provider, err := oidc.NewProvider(oidc.ClientContext(ctx, client), cfg.Issuer)
|
||||
discovery := *client
|
||||
recorder := &statusRecorder{next: http.DefaultTransport}
|
||||
discovery.Transport = recorder
|
||||
provider, err := oidc.NewProvider(oidc.ClientContext(ctx, &discovery), cfg.Issuer)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("auth: OIDC discovery: %w", err)
|
||||
return nil, discoveryError(err, int(recorder.status.Load()))
|
||||
}
|
||||
endpoint := provider.Endpoint()
|
||||
var discovered struct {
|
||||
@@ -118,6 +130,9 @@ func New(ctx context.Context, cfg Config, sessions Sessions, logger *slog.Logger
|
||||
{"token endpoint", endpoint.TokenURL},
|
||||
{"JWKS endpoint", discovered.JWKSURI},
|
||||
{"end session endpoint", discovered.EndSessionURI},
|
||||
// Groups come from userinfo when the ID token carries none, and the
|
||||
// access token is sent there.
|
||||
{"userinfo endpoint", provider.UserInfoEndpoint()},
|
||||
} {
|
||||
if err := checkEndpoint(e.name, e.raw); err != nil {
|
||||
return nil, err
|
||||
@@ -138,7 +153,9 @@ func New(ctx context.Context, cfg Config, sessions Sessions, logger *slog.Logger
|
||||
ClientID: cfg.ClientID,
|
||||
SupportedSigningAlgs: []string{oidc.RS256},
|
||||
}),
|
||||
provider: provider,
|
||||
client: client,
|
||||
allowed: slices.Clone(cfg.AllowedGroups),
|
||||
cookie: newLoginCookie(cfg.SecretKey),
|
||||
sessions: sessions,
|
||||
endSessionURL: discovered.EndSessionURI,
|
||||
@@ -146,6 +163,32 @@ func New(ctx context.Context, cfg Config, sessions Sessions, logger *slog.Logger
|
||||
}, nil
|
||||
}
|
||||
|
||||
// statusRecorder records the status of the last response it carries.
|
||||
type statusRecorder struct {
|
||||
next http.RoundTripper
|
||||
status atomic.Int32
|
||||
}
|
||||
|
||||
func (s *statusRecorder) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
resp, err := s.next.RoundTrip(req)
|
||||
if err == nil {
|
||||
s.status.Store(int32(resp.StatusCode))
|
||||
}
|
||||
return resp, err
|
||||
}
|
||||
|
||||
// discoveryError reports a failed discovery. go-oidc puts the provider's
|
||||
// response body in its error for a non-200 discovery response, so that
|
||||
// error is replaced by the status alone. Transport errors carry no response
|
||||
// content and keep their cause.
|
||||
func discoveryError(err error, status int) error {
|
||||
var urlErr *url.Error
|
||||
if !errors.As(err, &urlErr) && status != 0 && status != http.StatusOK {
|
||||
return fmt.Errorf("auth: OIDC discovery: provider answered HTTP %d", status)
|
||||
}
|
||||
return fmt.Errorf("auth: OIDC discovery: %w", err)
|
||||
}
|
||||
|
||||
// checkEndpoint applies secureurl.Check to the provider URL raw, naming the
|
||||
// endpoint in the error.
|
||||
func checkEndpoint(name, raw string) error {
|
||||
@@ -218,7 +261,9 @@ func (a *Auth) login(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// callback completes the flow: it checks state against the login cookie,
|
||||
// exchanges the code, verifies the ID token and nonce, then starts a session.
|
||||
// exchanges the code, verifies the ID token and nonce, then starts a session
|
||||
// if the account is in an allowed group. Membership is checked on every
|
||||
// login, so an account removed from the group is refused at its next one.
|
||||
func (a *Auth) callback(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
ctx := r.Context()
|
||||
@@ -256,6 +301,22 @@ func (a *Auth) callback(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "sign-in failed", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if !a.member(claims.Groups) {
|
||||
if claims.Groups == nil {
|
||||
a.logger.WarnContext(ctx, "sign-in refused: no groups claim in the ID token or userinfo", "subject", claims.Subject)
|
||||
} else {
|
||||
a.logger.WarnContext(ctx, "sign-in refused: not in an allowed group", "subject", claims.Subject)
|
||||
}
|
||||
// A session this browser already holds ends too, so a refused
|
||||
// sign-in never leaves it signed in.
|
||||
if err := a.sessions.EndSession(w, r); err != nil {
|
||||
a.logger.ErrorContext(ctx, "end session", "error", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
a.refuse(w, r)
|
||||
return
|
||||
}
|
||||
if err := a.sessions.StartSession(w, r, claims); err != nil {
|
||||
a.logger.ErrorContext(ctx, "start session", "error", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
@@ -278,7 +339,8 @@ func (a *Auth) logout(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// verify exchanges code for tokens and returns the verified ID token claims.
|
||||
// The verifier enforces RS256, issuer, audience and expiry; nonce is checked
|
||||
// here.
|
||||
// here. When the ID token has no groups claim, Groups comes from userinfo;
|
||||
// it stays nil when neither has one.
|
||||
func (a *Auth) verify(ctx context.Context, code string, state loginState) (Claims, error) {
|
||||
token, err := a.oauth.Exchange(context.WithValue(ctx, oauth2.HTTPClient, a.client), code,
|
||||
oauth2.VerifierOption(state.Verifier))
|
||||
@@ -303,9 +365,50 @@ func (a *Auth) verify(ctx context.Context, code string, state loginState) (Claim
|
||||
if claims.Subject == "" {
|
||||
return Claims{}, errors.New("ID token has no subject")
|
||||
}
|
||||
if claims.Groups == nil {
|
||||
groups, err := a.userinfoGroups(ctx, token, claims.Subject)
|
||||
if err != nil {
|
||||
return Claims{}, err
|
||||
}
|
||||
claims.Groups = groups
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// userinfoGroups returns the groups claim from the userinfo endpoint, nil if
|
||||
// it has none. The userinfo subject must match the ID token's subject.
|
||||
func (a *Auth) userinfoGroups(ctx context.Context, token *oauth2.Token, subject string) ([]string, error) {
|
||||
info, err := a.provider.UserInfo(oidc.ClientContext(ctx, a.client), oauth2.StaticTokenSource(token))
|
||||
if err != nil {
|
||||
return nil, userinfoError(err)
|
||||
}
|
||||
if info.Subject != subject {
|
||||
return nil, errors.New("userinfo subject does not match the ID token")
|
||||
}
|
||||
var claims struct {
|
||||
Groups []string `json:"groups"`
|
||||
}
|
||||
if err := info.Claims(&claims); err != nil {
|
||||
return nil, fmt.Errorf("decode userinfo claims: %w", err)
|
||||
}
|
||||
return claims.Groups, nil
|
||||
}
|
||||
|
||||
// userinfoError reduces a failed userinfo request to a fixed category, like
|
||||
// exchangeError: go-oidc's error text can carry the response body.
|
||||
func userinfoError(err error) error {
|
||||
var transport *url.Error
|
||||
if errors.As(err, &transport) {
|
||||
return fmt.Errorf("userinfo: request failed: %s: %w", transport.Op, transport.Err)
|
||||
}
|
||||
return errors.New("userinfo: endpoint returned an unusable response")
|
||||
}
|
||||
|
||||
// member reports whether groups contains an allowed group.
|
||||
func (a *Auth) member(groups []string) bool {
|
||||
return slices.ContainsFunc(groups, func(g string) bool { return slices.Contains(a.allowed, g) })
|
||||
}
|
||||
|
||||
// exchangeError reduces a failed code exchange to a fixed category and safe
|
||||
// metadata. The oauth2 error text can carry the token endpoint's response
|
||||
// body or error_description, which may echo credentials, so it is dropped.
|
||||
|
||||
+119
-30
@@ -11,6 +11,7 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"maps"
|
||||
@@ -36,6 +37,9 @@ const (
|
||||
|
||||
var testPublicURL = &url.URL{Scheme: "https", Host: "meals.example.com"}
|
||||
|
||||
// testAllowedGroups are the household's groups in tests.
|
||||
var testAllowedGroups = []string{"mealquest-admin", "mealquest-family"}
|
||||
|
||||
// fakeProvider is an OIDC provider serving discovery, JWKS and a token
|
||||
// endpoint. Each issued code maps to the ID token the token endpoint returns.
|
||||
type fakeProvider struct {
|
||||
@@ -50,6 +54,12 @@ type fakeProvider struct {
|
||||
// tokenReply, when set, is returned by the token endpoint instead of a
|
||||
// token.
|
||||
tokenReply *tokenReply
|
||||
// userinfo is the userinfo endpoint's response; userinfoHits counts
|
||||
// requests to it.
|
||||
userinfo map[string]any
|
||||
userinfoHits int
|
||||
// userinfoReply, when set, is returned by the userinfo endpoint instead.
|
||||
userinfoReply *tokenReply
|
||||
}
|
||||
|
||||
type tokenReply struct {
|
||||
@@ -73,6 +83,7 @@ func newFakeProvider(t *testing.T) *fakeProvider {
|
||||
mux.HandleFunc("GET /.well-known/openid-configuration", p.discovery)
|
||||
mux.HandleFunc("GET /jwks", p.jwks)
|
||||
mux.HandleFunc("POST /token", p.token)
|
||||
mux.HandleFunc("GET /userinfo", p.userinfoEndpoint)
|
||||
p.srv = httptest.NewServer(mux)
|
||||
t.Cleanup(p.srv.Close)
|
||||
return p
|
||||
@@ -86,6 +97,7 @@ func (p *fakeProvider) discovery(w http.ResponseWriter, _ *http.Request) {
|
||||
"authorization_endpoint": p.issuer() + "/authorize",
|
||||
"token_endpoint": p.issuer() + "/token",
|
||||
"jwks_uri": p.issuer() + "/jwks",
|
||||
"userinfo_endpoint": p.issuer() + "/userinfo",
|
||||
"end_session_endpoint": p.issuer() + "/end-session/",
|
||||
"response_types_supported": []string{"code"},
|
||||
"subject_types_supported": []string{"public"},
|
||||
@@ -148,6 +160,41 @@ func (p *fakeProvider) token(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// userinfoEndpoint checks the access token and returns p.userinfo, which
|
||||
// defaults to the test user without a groups claim.
|
||||
func (p *fakeProvider) userinfoEndpoint(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer access-token" {
|
||||
http.Error(w, `{"error":"invalid_token"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
p.mu.Lock()
|
||||
p.userinfoHits++
|
||||
info, reply := p.userinfo, p.userinfoReply
|
||||
p.mu.Unlock()
|
||||
if reply != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(reply.status)
|
||||
io.WriteString(w, reply.body)
|
||||
return
|
||||
}
|
||||
if info == nil {
|
||||
info = map[string]any{"sub": "user-123", "email": "cook@example.com"}
|
||||
}
|
||||
writeJSON(w, info)
|
||||
}
|
||||
|
||||
func (p *fakeProvider) setUserinfo(info map[string]any) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.userinfo = info
|
||||
}
|
||||
|
||||
func (p *fakeProvider) userinfoRequests() int {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return p.userinfoHits
|
||||
}
|
||||
|
||||
// authorize stands in for the user signing in at the provider: it records a
|
||||
// code bound to the PKCE challenge and returns it.
|
||||
func (p *fakeProvider) authorize(challenge, idToken string) string {
|
||||
@@ -291,11 +338,12 @@ func newHarness(t *testing.T) *harness {
|
||||
sessions := &memorySessions{}
|
||||
logs := &syncBuffer{}
|
||||
a, err := New(context.Background(), Config{
|
||||
Issuer: p.issuer(),
|
||||
ClientID: testClientID,
|
||||
ClientSecret: testClientSecret,
|
||||
PublicURL: testPublicURL,
|
||||
SecretKey: []byte("server-secret-key"),
|
||||
Issuer: p.issuer(),
|
||||
ClientID: testClientID,
|
||||
ClientSecret: testClientSecret,
|
||||
PublicURL: testPublicURL,
|
||||
SecretKey: []byte("server-secret-key"),
|
||||
AllowedGroups: testAllowedGroups,
|
||||
}, sessions, slog.New(slog.NewTextHandler(logs, nil)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -730,28 +778,65 @@ func TestNewFailsWhenDiscoveryFails(t *testing.T) {
|
||||
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"),
|
||||
Issuer: srv.URL,
|
||||
ClientID: testClientID,
|
||||
ClientSecret: testClientSecret,
|
||||
PublicURL: testPublicURL,
|
||||
SecretKey: []byte("k"),
|
||||
AllowedGroups: testAllowedGroups,
|
||||
}, &memorySessions{}, slog.New(slog.DiscardHandler))
|
||||
if err == nil {
|
||||
t.Fatal("New succeeded without a discovery document")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDiscoveryErrorOmitsProviderBody(t *testing.T) {
|
||||
t.Parallel()
|
||||
const sentinel = "sentinel-discovery-body-7f3a"
|
||||
for _, status := range []int{http.StatusNotFound, http.StatusInternalServerError} {
|
||||
t.Run(http.StatusText(status), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(status)
|
||||
io.WriteString(w, sentinel)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
logs := &syncBuffer{}
|
||||
_, err := New(context.Background(), Config{
|
||||
Issuer: srv.URL,
|
||||
ClientID: testClientID,
|
||||
ClientSecret: testClientSecret,
|
||||
PublicURL: testPublicURL,
|
||||
SecretKey: []byte("k"),
|
||||
AllowedGroups: testAllowedGroups,
|
||||
}, &memorySessions{}, slog.New(slog.NewTextHandler(logs, nil)))
|
||||
if err == nil {
|
||||
t.Fatal("New succeeded without a discovery document")
|
||||
}
|
||||
// cmd/server logs the returned error at startup.
|
||||
slog.New(slog.NewTextHandler(logs, nil)).Error("startup failed", "err", err)
|
||||
if strings.Contains(logs.String(), sentinel) {
|
||||
t.Errorf("logs contain the provider response body: %s", logs)
|
||||
}
|
||||
if want := fmt.Sprintf("auth: OIDC discovery: provider answered HTTP %d", status); err.Error() != want {
|
||||
t.Errorf("error = %q, want %q", err, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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"),
|
||||
Issuer: p.issuer() + "/",
|
||||
ClientID: testClientID,
|
||||
ClientSecret: testClientSecret,
|
||||
PublicURL: testPublicURL,
|
||||
SecretKey: []byte("k"),
|
||||
AllowedGroups: testAllowedGroups,
|
||||
}, &memorySessions{}, slog.New(slog.DiscardHandler))
|
||||
if err == nil {
|
||||
t.Fatal("New accepted a provider reporting a different issuer")
|
||||
@@ -772,11 +857,12 @@ 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"),
|
||||
Issuer: issuer,
|
||||
ClientID: testClientID,
|
||||
ClientSecret: testClientSecret,
|
||||
PublicURL: testPublicURL,
|
||||
SecretKey: []byte("k"),
|
||||
AllowedGroups: testAllowedGroups,
|
||||
}, &memorySessions{}, slog.New(slog.DiscardHandler))
|
||||
if err == nil || !strings.Contains(err.Error(), "issuer") {
|
||||
t.Errorf("New(issuer %q) error = %v, want issuer rejected", issuer, err)
|
||||
@@ -793,6 +879,7 @@ func TestNewRejectsRemoteHTTPEndpoint(t *testing.T) {
|
||||
{"token_endpoint", "token endpoint"},
|
||||
{"jwks_uri", "JWKS endpoint"},
|
||||
{"end_session_endpoint", "end session endpoint"},
|
||||
{"userinfo_endpoint", "userinfo endpoint"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.field, func(t *testing.T) {
|
||||
@@ -800,11 +887,12 @@ func TestNewRejectsRemoteHTTPEndpoint(t *testing.T) {
|
||||
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"),
|
||||
Issuer: p.issuer(),
|
||||
ClientID: testClientID,
|
||||
ClientSecret: testClientSecret,
|
||||
PublicURL: testPublicURL,
|
||||
SecretKey: []byte("k"),
|
||||
AllowedGroups: testAllowedGroups,
|
||||
}, &memorySessions{}, slog.New(slog.DiscardHandler))
|
||||
if err == nil || !strings.Contains(err.Error(), tc.name) {
|
||||
t.Errorf("New error = %v, want %s rejected", err, tc.name)
|
||||
@@ -840,11 +928,12 @@ func TestCallbackRefusesTokenRedirectToHTTP(t *testing.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"),
|
||||
Issuer: p.issuer(),
|
||||
ClientID: testClientID,
|
||||
ClientSecret: testClientSecret,
|
||||
PublicURL: testPublicURL,
|
||||
SecretKey: []byte("server-secret-key"),
|
||||
AllowedGroups: testAllowedGroups,
|
||||
}, sessions, slog.New(slog.NewTextHandler(logs, nil)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// signInWith completes a login whose ID token has claims edited by edit.
|
||||
func (h *harness) signInWith(t *testing.T, edit func(claims map[string]any)) *http.Response {
|
||||
t.Helper()
|
||||
l := h.login(t, LoginPath)
|
||||
c := h.provider.claims(l.query.Get("nonce"))
|
||||
edit(c)
|
||||
return h.callback(l, l.query.Get("state"), h.provider.signRS256(c))
|
||||
}
|
||||
|
||||
// assertRefused checks resp is the refused-sign-in page and starts no
|
||||
// session.
|
||||
func assertRefused(t *testing.T, resp *http.Response) {
|
||||
t.Helper()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("callback status = %d, want 403", resp.StatusCode)
|
||||
}
|
||||
if ct := resp.Header.Get("Content-Type"); ct != "text/html; charset=utf-8" {
|
||||
t.Errorf("Content-Type = %q, want text/html; charset=utf-8", ct)
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"This kitchen is for the household only",
|
||||
`<form method="post" action="/auth/logout">`,
|
||||
"Sign out",
|
||||
} {
|
||||
if !strings.Contains(string(body), want) {
|
||||
t.Errorf("refused page lacks %q:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
for _, c := range resp.Cookies() {
|
||||
// Clearing a session cookie (negative Max-Age) is expected.
|
||||
if (c.Name == SessionCookieName || c.Name == "test_session") && c.MaxAge >= 0 {
|
||||
t.Errorf("refused sign-in set session cookie %s", c.Name)
|
||||
}
|
||||
}
|
||||
assertLoginCookieCleared(t, resp)
|
||||
}
|
||||
|
||||
func TestGroupGateMember(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Any one allowed group is enough, alongside groups that are not allowed.
|
||||
h := newHarness(t)
|
||||
resp := h.signInWith(t, func(c map[string]any) { c["groups"] = []string{"guests", "mealquest-family"} })
|
||||
if resp.StatusCode != http.StatusSeeOther {
|
||||
t.Fatalf("callback status = %d, want 303", resp.StatusCode)
|
||||
}
|
||||
if started := h.sessions.started(); len(started) != 1 || !slices.Equal(started[0].Groups, []string{"guests", "mealquest-family"}) {
|
||||
t.Errorf("sessions started = %+v, want one with the ID token groups", started)
|
||||
}
|
||||
if n := h.provider.userinfoRequests(); n != 0 {
|
||||
t.Errorf("userinfo requested %d times, want 0 when the ID token has groups", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupGateRefused(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
groups any // ID token groups claim; nil removes it
|
||||
userinfo map[string]any
|
||||
}{
|
||||
{name: "non-member", groups: []string{"guests", "MEALQUEST-ADMIN"}},
|
||||
{name: "empty groups claim", groups: []string{}},
|
||||
{name: "groups claim missing from ID token and userinfo", groups: nil},
|
||||
{
|
||||
name: "non-member in userinfo",
|
||||
groups: nil,
|
||||
userinfo: map[string]any{"sub": "user-123", "groups": []string{"guests"}},
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
if tc.userinfo != nil {
|
||||
h.provider.setUserinfo(tc.userinfo)
|
||||
}
|
||||
resp := h.signInWith(t, func(c map[string]any) {
|
||||
if tc.groups == nil {
|
||||
delete(c, "groups")
|
||||
} else {
|
||||
c["groups"] = tc.groups
|
||||
}
|
||||
})
|
||||
assertRefused(t, resp)
|
||||
if started := h.sessions.started(); len(started) != 0 {
|
||||
t.Errorf("sessions started = %+v, want none", started)
|
||||
}
|
||||
if !strings.Contains(h.logs.String(), "sign-in refused") {
|
||||
t.Errorf("refusal not logged:\n%s", h.logs.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupGateUsesUserinfoWhenIDTokenHasNoGroups(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
h.provider.setUserinfo(map[string]any{"sub": "user-123", "groups": []string{"mealquest-admin"}})
|
||||
resp := h.signInWith(t, func(c map[string]any) { delete(c, "groups") })
|
||||
if resp.StatusCode != http.StatusSeeOther {
|
||||
t.Fatalf("callback status = %d, want 303", resp.StatusCode)
|
||||
}
|
||||
if started := h.sessions.started(); len(started) != 1 || !slices.Equal(started[0].Groups, []string{"mealquest-admin"}) {
|
||||
t.Errorf("sessions started = %+v, want one with the userinfo groups", started)
|
||||
}
|
||||
if n := h.provider.userinfoRequests(); n != 1 {
|
||||
t.Errorf("userinfo requested %d times, want 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupGateUserinfoFailures(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
userinfo map[string]any
|
||||
}{
|
||||
{"subject mismatch", map[string]any{"sub": "someone-else", "groups": []string{"mealquest-admin"}}},
|
||||
{"malformed groups", map[string]any{"sub": "user-123", "groups": "mealquest-admin"}},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
h.provider.setUserinfo(tc.userinfo)
|
||||
resp := h.signInWith(t, func(c map[string]any) { delete(c, "groups") })
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("callback status = %d, want 401", resp.StatusCode)
|
||||
}
|
||||
if started := h.sessions.started(); len(started) != 0 {
|
||||
t.Errorf("sessions started = %+v, want none", started)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A failed userinfo request fails the sign-in without logging the
|
||||
// endpoint's response, which may echo credentials.
|
||||
func TestGroupGateUserinfoErrorNotLogged(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
h.provider.mu.Lock()
|
||||
h.provider.userinfoReply = &tokenReply{status: http.StatusInternalServerError, body: `{"error":"leaked-s3cret"}`}
|
||||
h.provider.mu.Unlock()
|
||||
resp := h.signInWith(t, func(c map[string]any) { delete(c, "groups") })
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("callback status = %d, want 401", resp.StatusCode)
|
||||
}
|
||||
if started := h.sessions.started(); len(started) != 0 {
|
||||
t.Errorf("sessions started = %+v, want none", started)
|
||||
}
|
||||
logs := h.logs.String()
|
||||
if !strings.Contains(logs, "userinfo") || strings.Contains(logs, "leaked-s3cret") {
|
||||
t.Errorf("logs = %s, want the userinfo failure without the response body", logs)
|
||||
}
|
||||
}
|
||||
|
||||
// Membership is checked at every login: after removal from the group, the
|
||||
// next login is refused and starts no session.
|
||||
func TestGroupGateRemovedMemberRefusedAtNextLogin(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newSessionHarness(t)
|
||||
h.signIn(t)
|
||||
if n := len(h.rows(t)); n != 1 {
|
||||
t.Fatalf("sessions after first login = %d, want 1", n)
|
||||
}
|
||||
|
||||
resp := h.signInWith(t, func(c map[string]any) { c["groups"] = []string{"former-household"} })
|
||||
assertRefused(t, resp)
|
||||
if n := len(h.rows(t)); n != 1 {
|
||||
t.Errorf("sessions after refused login = %d, want still 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
// A refused sign-in from a browser that still holds a session ends that
|
||||
// session and clears its cookie.
|
||||
func TestGroupGateRefusalEndsExistingSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newSessionHarness(t)
|
||||
_, cookie := h.signIn(t)
|
||||
|
||||
l := h.login(t, LoginPath)
|
||||
c := h.provider.claims(l.query.Get("nonce"))
|
||||
c["groups"] = []string{"former-household"}
|
||||
code := h.provider.authorize(l.query.Get("code_challenge"), h.provider.signRS256(c))
|
||||
q := url.Values{"code": {code}, "state": {l.query.Get("state")}}
|
||||
resp := h.get(CallbackPath+"?"+q.Encode(), l.cookie, cookie)
|
||||
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("callback status = %d, want 403", resp.StatusCode)
|
||||
}
|
||||
if n := len(h.rows(t)); n != 0 {
|
||||
t.Errorf("sessions after refused login = %d, want 0", n)
|
||||
}
|
||||
if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) {
|
||||
t.Errorf("old session lookup error = %v, want ErrNoSession", err)
|
||||
}
|
||||
cleared := false
|
||||
for _, c := range resp.Cookies() {
|
||||
cleared = cleared || (c.Name == SessionCookieName && c.MaxAge < 0)
|
||||
}
|
||||
if !cleared {
|
||||
t.Error("refused sign-in did not clear the session cookie")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupGateRefusalEndSessionError(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
h.sessions.err = errors.New("database down")
|
||||
resp := h.signInWith(t, func(c map[string]any) { c["groups"] = []string{"guests"} })
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Errorf("callback status = %d, want 500", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRequiresAllowedGroups(t *testing.T) {
|
||||
t.Parallel()
|
||||
p := newFakeProvider(t)
|
||||
_, err := New(context.Background(), Config{
|
||||
Issuer: p.issuer(),
|
||||
ClientID: testClientID,
|
||||
ClientSecret: testClientSecret,
|
||||
PublicURL: testPublicURL,
|
||||
SecretKey: []byte("k"),
|
||||
}, &memorySessions{}, slog.New(slog.DiscardHandler))
|
||||
if err == nil || !strings.Contains(err.Error(), "allowed group") {
|
||||
t.Errorf("New error = %v, want missing allowed groups named", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRequiresUserinfoEndpoint(t *testing.T) {
|
||||
t.Parallel()
|
||||
p := newFakeProvider(t)
|
||||
p.discoveryOverrides = map[string]any{"userinfo_endpoint": nil}
|
||||
_, err := New(context.Background(), Config{
|
||||
Issuer: p.issuer(),
|
||||
ClientID: testClientID,
|
||||
ClientSecret: testClientSecret,
|
||||
PublicURL: testPublicURL,
|
||||
SecretKey: []byte("k"),
|
||||
AllowedGroups: testAllowedGroups,
|
||||
}, &memorySessions{}, slog.New(slog.DiscardHandler))
|
||||
if err == nil || !strings.Contains(err.Error(), "userinfo endpoint") {
|
||||
t.Errorf("New error = %v, want the missing userinfo endpoint named", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// refusedPage is shown when a sign-in succeeds at the provider but the
|
||||
// account is not in the household's group. Signing out ends the provider
|
||||
// session too, so another account can sign in.
|
||||
const refusedPage = `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>MealQuest</title>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>This kitchen is for the household only</h1>
|
||||
<p>The account you signed in with is not part of our kitchen.</p>
|
||||
<form method="post" action="` + LogoutPath + `">
|
||||
<button type="submit">Sign out</button>
|
||||
</form>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
// refuse writes the refused-sign-in page with 403.
|
||||
func (a *Auth) refuse(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
if _, err := io.WriteString(w, refusedPage); err != nil {
|
||||
a.logger.WarnContext(r.Context(), "write refused sign-in page", "error", err)
|
||||
}
|
||||
}
|
||||
@@ -62,11 +62,12 @@ func newSessionHarness(t *testing.T) *sessionHarness {
|
||||
p := newFakeProvider(t)
|
||||
logs := &syncBuffer{}
|
||||
a, err := New(ctx, Config{
|
||||
Issuer: p.issuer(),
|
||||
ClientID: testClientID,
|
||||
ClientSecret: testClientSecret,
|
||||
PublicURL: testPublicURL,
|
||||
SecretKey: []byte("server-secret-key"),
|
||||
Issuer: p.issuer(),
|
||||
ClientID: testClientID,
|
||||
ClientSecret: testClientSecret,
|
||||
PublicURL: testPublicURL,
|
||||
SecretKey: []byte("server-secret-key"),
|
||||
AllowedGroups: testAllowedGroups,
|
||||
}, sessions, slog.New(slog.NewTextHandler(logs, nil)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -490,11 +491,12 @@ func TestNewRequiresEndSessionEndpoint(t *testing.T) {
|
||||
p := newFakeProvider(t)
|
||||
p.discoveryOverrides = map[string]any{"end_session_endpoint": nil}
|
||||
_, err := New(context.Background(), Config{
|
||||
Issuer: p.issuer(),
|
||||
ClientID: testClientID,
|
||||
ClientSecret: testClientSecret,
|
||||
PublicURL: testPublicURL,
|
||||
SecretKey: []byte("k"),
|
||||
Issuer: p.issuer(),
|
||||
ClientID: testClientID,
|
||||
ClientSecret: testClientSecret,
|
||||
PublicURL: testPublicURL,
|
||||
SecretKey: []byte("k"),
|
||||
AllowedGroups: testAllowedGroups,
|
||||
}, &memorySessions{}, slog.New(slog.DiscardHandler))
|
||||
if err == nil || !strings.Contains(err.Error(), "end session endpoint") {
|
||||
t.Errorf("New error = %v, want the missing end session endpoint named", err)
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secureurl"
|
||||
)
|
||||
|
||||
@@ -30,6 +31,8 @@ const (
|
||||
|
||||
EnvSessionIdle = "MEALQUEST_SESSION_IDLE"
|
||||
EnvSessionMax = "MEALQUEST_SESSION_MAX"
|
||||
|
||||
EnvTrustedProxy = "MEALQUEST_TRUSTED_PROXY"
|
||||
)
|
||||
|
||||
// Session lifetime defaults (D-004): long enough that the kitchen tablet
|
||||
@@ -58,6 +61,10 @@ type Config struct {
|
||||
SessionIdle time.Duration
|
||||
// SessionMax ends every session this long after sign-in, used or not.
|
||||
SessionMax time.Duration
|
||||
|
||||
// TrustedProxy lists the reverse proxies whose X-Forwarded-For and
|
||||
// X-Forwarded-Proto headers are honoured. Empty trusts no peer.
|
||||
TrustedProxy forwarded.Trusted
|
||||
}
|
||||
|
||||
// PublicOrigin returns the scheme and host of PublicURL, the only parts of it
|
||||
@@ -88,6 +95,8 @@ func load(lookup func(string) (string, bool)) (Config, error) {
|
||||
|
||||
SessionIdle: r.sessionDuration(EnvSessionIdle, DefaultSessionIdle),
|
||||
SessionMax: r.sessionDuration(EnvSessionMax, DefaultSessionMax),
|
||||
|
||||
TrustedProxy: r.trustedProxy(EnvTrustedProxy),
|
||||
}
|
||||
if cfg.SessionIdle > 0 && cfg.SessionMax > 0 && cfg.SessionMax < cfg.SessionIdle {
|
||||
r.fail(EnvSessionMax, "must be at least %s (%s)", EnvSessionIdle, cfg.SessionIdle)
|
||||
@@ -177,6 +186,18 @@ func (r *reader) list(name string) []string {
|
||||
return items
|
||||
}
|
||||
|
||||
// trustedProxy parses an optional comma-separated list of IP addresses and
|
||||
// CIDR prefixes with forwarded.ParseTrusted. Unset or blank trusts no peer.
|
||||
func (r *reader) trustedProxy(name string) forwarded.Trusted {
|
||||
v, _ := r.lookup(name)
|
||||
t, err := forwarded.ParseTrusted(v)
|
||||
if err != nil {
|
||||
r.fail(name, "%v", err)
|
||||
return nil
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func (r *reader) listenAddr(name string) string {
|
||||
v, ok := r.required(name)
|
||||
if !ok {
|
||||
|
||||
@@ -3,6 +3,7 @@ package config
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -11,6 +12,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
|
||||
)
|
||||
|
||||
func TestLoad(t *testing.T) {
|
||||
@@ -293,6 +296,48 @@ func TestLoad(t *testing.T) {
|
||||
env: with(map[string]string{EnvSessionMax: "719h"}),
|
||||
badVars: []string{EnvSessionMax},
|
||||
},
|
||||
{
|
||||
name: "trusted proxy IP",
|
||||
env: with(map[string]string{EnvTrustedProxy: "10.60.1.33"}),
|
||||
},
|
||||
{
|
||||
name: "trusted proxy IPs and CIDRs",
|
||||
env: with(map[string]string{EnvTrustedProxy: "10.60.1.33, 172.16.0.0/12,2001:db8::/32,::1"}),
|
||||
},
|
||||
{
|
||||
name: "blank trusted proxy trusts no peer",
|
||||
env: with(map[string]string{EnvTrustedProxy: " "}),
|
||||
},
|
||||
{
|
||||
name: "trusted proxy not an IP",
|
||||
env: with(map[string]string{EnvTrustedProxy: "traefik"}),
|
||||
badVars: []string{EnvTrustedProxy},
|
||||
},
|
||||
{
|
||||
name: "trusted proxy invalid CIDR",
|
||||
env: with(map[string]string{EnvTrustedProxy: "10.60.1.0/33"}),
|
||||
badVars: []string{EnvTrustedProxy},
|
||||
},
|
||||
{
|
||||
name: "trusted proxy CIDR with host bits",
|
||||
env: with(map[string]string{EnvTrustedProxy: "10.60.1.33/24"}),
|
||||
badVars: []string{EnvTrustedProxy},
|
||||
},
|
||||
{
|
||||
name: "trusted proxy empty entry",
|
||||
env: with(map[string]string{EnvTrustedProxy: "10.60.1.33,,10.60.1.34"}),
|
||||
badVars: []string{EnvTrustedProxy},
|
||||
},
|
||||
{
|
||||
name: "trusted proxy trusting everyone",
|
||||
env: with(map[string]string{EnvTrustedProxy: "0.0.0.0/0"}),
|
||||
badVars: []string{EnvTrustedProxy},
|
||||
},
|
||||
{
|
||||
name: "trusted proxy with port",
|
||||
env: with(map[string]string{EnvTrustedProxy: "10.60.1.33:443"}),
|
||||
badVars: []string{EnvTrustedProxy},
|
||||
},
|
||||
{
|
||||
name: "several invalid values are all named",
|
||||
env: with(map[string]string{
|
||||
@@ -402,8 +447,13 @@ func TestLoadValues(t *testing.T) {
|
||||
t.Errorf("SessionIdle, SessionMax = %v, %v, want defaults 720h, 4320h", cfg.SessionIdle, cfg.SessionMax)
|
||||
}
|
||||
|
||||
if cfg.TrustedProxy != nil {
|
||||
t.Errorf("TrustedProxy = %v, want none when unset", cfg.TrustedProxy)
|
||||
}
|
||||
|
||||
env[EnvSessionIdle] = "12h"
|
||||
env[EnvSessionMax] = "36h"
|
||||
env[EnvTrustedProxy] = "10.60.1.33, 172.16.0.0/12"
|
||||
cfg, err = load(func(k string) (string, bool) { v, ok := env[k]; return v, ok })
|
||||
if err != nil {
|
||||
t.Fatalf("load() error = %v", err)
|
||||
@@ -411,6 +461,10 @@ func TestLoadValues(t *testing.T) {
|
||||
if cfg.SessionIdle != 12*time.Hour || cfg.SessionMax != 36*time.Hour {
|
||||
t.Errorf("SessionIdle, SessionMax = %v, %v, want 12h, 36h", cfg.SessionIdle, cfg.SessionMax)
|
||||
}
|
||||
want := forwarded.Trusted{netip.MustParsePrefix("10.60.1.33/32"), netip.MustParsePrefix("172.16.0.0/12")}
|
||||
if !slices.Equal(cfg.TrustedProxy, want) {
|
||||
t.Errorf("TrustedProxy = %v, want %v", cfg.TrustedProxy, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretRedacted(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
// Package forwarded resolves where a request really came from. The service
|
||||
// runs behind a reverse proxy (Traefik), so the TCP peer is usually the proxy;
|
||||
// the X-Forwarded-For and X-Forwarded-Proto headers it adds are believed only
|
||||
// when the peer is a trusted proxy. From anyone else they are ignored, so a
|
||||
// client cannot spoof its address or scheme.
|
||||
package forwarded
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Header names honoured from trusted proxies.
|
||||
const (
|
||||
HeaderFor = "X-Forwarded-For"
|
||||
HeaderProto = "X-Forwarded-Proto"
|
||||
)
|
||||
|
||||
// ErrInvalidHeader marks a malformed forwarded header from a trusted peer.
|
||||
var ErrInvalidHeader = errors.New("invalid forwarded header")
|
||||
|
||||
// Trusted is the set of proxy addresses whose forwarded headers are honoured.
|
||||
// The zero value trusts no peer.
|
||||
type Trusted []netip.Prefix
|
||||
|
||||
// ParseTrusted parses a comma-separated list of IP addresses and CIDR
|
||||
// prefixes. A blank list trusts no peer. Each entry must be a plain address
|
||||
// or a prefix with no host bits set; zones, IPv4-mapped IPv6 forms and
|
||||
// zero-length prefixes, which would trust every peer, are rejected.
|
||||
func ParseTrusted(s string) (Trusted, error) {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var t Trusted
|
||||
for _, entry := range strings.Split(s, ",") {
|
||||
entry = strings.TrimSpace(entry)
|
||||
if entry == "" {
|
||||
return nil, fmt.Errorf("empty entry in comma-separated list %q", s)
|
||||
}
|
||||
p, err := parseEntry(entry)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t = append(t, p)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func parseEntry(entry string) (netip.Prefix, error) {
|
||||
var p netip.Prefix
|
||||
if strings.Contains(entry, "/") {
|
||||
var err error
|
||||
p, err = netip.ParsePrefix(entry)
|
||||
if err != nil {
|
||||
return netip.Prefix{}, fmt.Errorf("invalid CIDR %q", entry)
|
||||
}
|
||||
if p != p.Masked() {
|
||||
return netip.Prefix{}, fmt.Errorf("CIDR %q has host bits set (did you mean %s?)", entry, p.Masked())
|
||||
}
|
||||
} else {
|
||||
a, err := netip.ParseAddr(entry)
|
||||
if err != nil {
|
||||
return netip.Prefix{}, fmt.Errorf("invalid IP address %q", entry)
|
||||
}
|
||||
if a.Zone() != "" {
|
||||
return netip.Prefix{}, fmt.Errorf("IP address %q must not have a zone", entry)
|
||||
}
|
||||
p = netip.PrefixFrom(a, a.BitLen())
|
||||
}
|
||||
switch {
|
||||
case p.Addr().Is4In6():
|
||||
return netip.Prefix{}, fmt.Errorf("%q is IPv4-mapped IPv6; write the IPv4 form", entry)
|
||||
case p.Bits() == 0:
|
||||
return netip.Prefix{}, fmt.Errorf("%q would trust every peer", entry)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// Contains reports whether a is a trusted proxy address. IPv4-mapped IPv6
|
||||
// addresses, as a dual-stack listener reports IPv4 peers, match their IPv4
|
||||
// form.
|
||||
func (t Trusted) Contains(a netip.Addr) bool {
|
||||
a = normalize(a)
|
||||
for _, p := range t {
|
||||
if p.Contains(a) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func normalize(a netip.Addr) netip.Addr { return a.Unmap().WithZone("") }
|
||||
|
||||
// Origin is where a request came from.
|
||||
type Origin struct {
|
||||
// ClientIP is the client's address.
|
||||
ClientIP netip.Addr
|
||||
// Scheme is "https" or "http": the scheme the client used.
|
||||
Scheme string
|
||||
}
|
||||
|
||||
// Resolve works out r's Origin. When the TCP peer (r.RemoteAddr) is not in
|
||||
// trusted, both forwarded headers are ignored: the client is the peer and the
|
||||
// scheme is that of the connection.
|
||||
//
|
||||
// When the peer is trusted:
|
||||
// - X-Forwarded-For entries (across all its header lines, in order) are
|
||||
// walked right to left, skipping trusted proxy addresses; the first
|
||||
// untrusted address is the client. Entries left of it are client-supplied
|
||||
// and not examined. If every entry is trusted the leftmost is the client;
|
||||
// with no X-Forwarded-For the peer is the client.
|
||||
// - X-Forwarded-Proto, if present, must be a single "http" or "https" and
|
||||
// is the scheme; without it the connection's scheme is used.
|
||||
//
|
||||
// A malformed header from a trusted peer is an error wrapping
|
||||
// ErrInvalidHeader; any other error means RemoteAddr is not an IP and port.
|
||||
func Resolve(r *http.Request, trusted Trusted) (Origin, error) {
|
||||
peer, err := netip.ParseAddrPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return Origin{}, fmt.Errorf("peer address %q is not an IP and port", r.RemoteAddr)
|
||||
}
|
||||
o := Origin{ClientIP: normalize(peer.Addr()), Scheme: "http"}
|
||||
if r.TLS != nil {
|
||||
o.Scheme = "https"
|
||||
}
|
||||
if !trusted.Contains(o.ClientIP) {
|
||||
return o, nil
|
||||
}
|
||||
if values := r.Header.Values(HeaderFor); len(values) > 0 {
|
||||
o.ClientIP, err = clientFromChain(strings.Split(strings.Join(values, ","), ","), trusted)
|
||||
if err != nil {
|
||||
return Origin{}, err
|
||||
}
|
||||
}
|
||||
switch values := r.Header.Values(HeaderProto); len(values) {
|
||||
case 0:
|
||||
case 1:
|
||||
switch scheme := strings.ToLower(strings.TrimSpace(values[0])); scheme {
|
||||
case "http", "https":
|
||||
o.Scheme = scheme
|
||||
default:
|
||||
return Origin{}, fmt.Errorf("%w: %s %q is not http or https", ErrInvalidHeader, HeaderProto, values[0])
|
||||
}
|
||||
default:
|
||||
return Origin{}, fmt.Errorf("%w: %d %s headers, want one", ErrInvalidHeader, len(values), HeaderProto)
|
||||
}
|
||||
return o, nil
|
||||
}
|
||||
|
||||
// clientFromChain returns the rightmost untrusted address in chain, or the
|
||||
// leftmost address when all are trusted.
|
||||
func clientFromChain(chain []string, trusted Trusted) (netip.Addr, error) {
|
||||
var client netip.Addr
|
||||
for i := len(chain) - 1; i >= 0; i-- {
|
||||
a, err := netip.ParseAddr(strings.TrimSpace(chain[i]))
|
||||
if err != nil {
|
||||
return netip.Addr{}, fmt.Errorf("%w: %s entry %q is not an IP address", ErrInvalidHeader, HeaderFor, strings.TrimSpace(chain[i]))
|
||||
}
|
||||
client = normalize(a)
|
||||
if !trusted.Contains(client) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
type contextKey struct{}
|
||||
|
||||
// NewContext returns ctx carrying o.
|
||||
func NewContext(ctx context.Context, o Origin) context.Context {
|
||||
return context.WithValue(ctx, contextKey{}, o)
|
||||
}
|
||||
|
||||
// FromContext returns the Origin ctx carries; ok is false when the request
|
||||
// was not resolved.
|
||||
func FromContext(ctx context.Context) (o Origin, ok bool) {
|
||||
o, ok = ctx.Value(contextKey{}).(Origin)
|
||||
return o, ok
|
||||
}
|
||||
|
||||
// ClientIP returns the resolved client address ctx carries.
|
||||
func ClientIP(ctx context.Context) (netip.Addr, bool) {
|
||||
o, ok := FromContext(ctx)
|
||||
return o.ClientIP, ok
|
||||
}
|
||||
|
||||
// Scheme returns the resolved request scheme ctx carries.
|
||||
func Scheme(ctx context.Context) (string, bool) {
|
||||
o, ok := FromContext(ctx)
|
||||
return o.Scheme, ok
|
||||
}
|
||||
|
||||
// LogAttr is the log attribute key for the resolved client address.
|
||||
const LogAttr = "client_ip"
|
||||
|
||||
// LogHandler wraps h so every record logged with a resolved request's
|
||||
// context carries the client address as LogAttr.
|
||||
func LogHandler(h slog.Handler) slog.Handler { return logHandler{h} }
|
||||
|
||||
type logHandler struct{ slog.Handler }
|
||||
|
||||
func (h logHandler) Handle(ctx context.Context, rec slog.Record) error {
|
||||
if ip, ok := ClientIP(ctx); ok {
|
||||
rec = rec.Clone()
|
||||
rec.AddAttrs(slog.String(LogAttr, ip.String()))
|
||||
}
|
||||
return h.Handler.Handle(ctx, rec)
|
||||
}
|
||||
|
||||
func (h logHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||
return logHandler{h.Handler.WithAttrs(attrs)}
|
||||
}
|
||||
|
||||
func (h logHandler) WithGroup(name string) slog.Handler {
|
||||
return logHandler{h.Handler.WithGroup(name)}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
package forwarded
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseTrusted(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
in string
|
||||
want []string
|
||||
wantErr string
|
||||
}{
|
||||
{in: "", want: nil},
|
||||
{in: " ", want: nil},
|
||||
{in: "10.60.1.33", want: []string{"10.60.1.33/32"}},
|
||||
{in: "10.60.1.33, 172.16.0.0/12", want: []string{"10.60.1.33/32", "172.16.0.0/12"}},
|
||||
{in: "2001:db8::1,2001:db8::/32", want: []string{"2001:db8::1/128", "2001:db8::/32"}},
|
||||
{in: "traefik", wantErr: "invalid IP address"},
|
||||
{in: "10.60.1.33:443", wantErr: "invalid IP address"},
|
||||
{in: "10.60.1.0/33", wantErr: "invalid CIDR"},
|
||||
{in: "10.60.1.33/24", wantErr: "did you mean 10.60.1.0/24"},
|
||||
{in: "10.60.1.33,,10.60.1.34", wantErr: "empty entry"},
|
||||
{in: "10.60.1.33,", wantErr: "empty entry"},
|
||||
{in: "fe80::1%eth0", wantErr: "zone"},
|
||||
{in: "::ffff:10.60.1.33", wantErr: "IPv4-mapped"},
|
||||
{in: "0.0.0.0/0", wantErr: "every peer"},
|
||||
{in: "::/0", wantErr: "every peer"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got, err := ParseTrusted(tt.in)
|
||||
if tt.wantErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Errorf("ParseTrusted(%q) error = %v, want one containing %q", tt.in, err, tt.wantErr)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("ParseTrusted(%q) error = %v", tt.in, err)
|
||||
continue
|
||||
}
|
||||
var gotStr []string
|
||||
for _, p := range got {
|
||||
gotStr = append(gotStr, p.String())
|
||||
}
|
||||
if !slices.Equal(gotStr, tt.want) {
|
||||
t.Errorf("ParseTrusted(%q) = %v, want %v", tt.in, gotStr, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrustedContainsMappedIPv4(t *testing.T) {
|
||||
t.Parallel()
|
||||
trusted := mustTrusted(t, "10.60.1.33")
|
||||
if !trusted.Contains(netip.MustParseAddr("::ffff:10.60.1.33")) {
|
||||
t.Error("IPv4-mapped form of a trusted address is not trusted")
|
||||
}
|
||||
if Trusted(nil).Contains(netip.MustParseAddr("10.60.1.33")) {
|
||||
t.Error("an empty set trusts a peer")
|
||||
}
|
||||
}
|
||||
|
||||
func mustTrusted(t *testing.T, s string) Trusted {
|
||||
t.Helper()
|
||||
tr, err := ParseTrusted(s)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return tr
|
||||
}
|
||||
|
||||
func TestResolve(t *testing.T) {
|
||||
t.Parallel()
|
||||
// 10.60.1.33 is Traefik; 10.60.2.0/24 is an inner proxy tier.
|
||||
trusted := mustTrusted(t, "10.60.1.33,10.60.2.0/24")
|
||||
tests := []struct {
|
||||
name string
|
||||
peer string
|
||||
tls bool
|
||||
xff []string
|
||||
xfp []string
|
||||
wantIP string
|
||||
wantScheme string
|
||||
}{
|
||||
{
|
||||
name: "untrusted peer, no headers",
|
||||
peer: "198.51.100.9:5000", wantIP: "198.51.100.9", wantScheme: "http",
|
||||
},
|
||||
{
|
||||
name: "untrusted peer over TLS",
|
||||
peer: "198.51.100.9:5000", tls: true, wantIP: "198.51.100.9", wantScheme: "https",
|
||||
},
|
||||
{
|
||||
name: "untrusted peer spoofing both headers is ignored",
|
||||
peer: "198.51.100.9:5000", xff: []string{"203.0.113.7"}, xfp: []string{"https"},
|
||||
wantIP: "198.51.100.9", wantScheme: "http",
|
||||
},
|
||||
{
|
||||
name: "untrusted peer's malformed headers are ignored, not rejected",
|
||||
peer: "198.51.100.9:5000", xff: []string{"not-an-ip"}, xfp: []string{"gopher"},
|
||||
wantIP: "198.51.100.9", wantScheme: "http",
|
||||
},
|
||||
{
|
||||
name: "trusted peer: client from X-Forwarded-For, scheme from X-Forwarded-Proto",
|
||||
peer: "10.60.1.33:41000", xff: []string{"203.0.113.7"}, xfp: []string{"https"},
|
||||
wantIP: "203.0.113.7", wantScheme: "https",
|
||||
},
|
||||
{
|
||||
name: "trusted peer seen as IPv4-mapped IPv6",
|
||||
peer: "[::ffff:10.60.1.33]:41000", xff: []string{"203.0.113.7"}, xfp: []string{"https"},
|
||||
wantIP: "203.0.113.7", wantScheme: "https",
|
||||
},
|
||||
{
|
||||
name: "trusted peer without headers is itself the client",
|
||||
peer: "10.60.1.33:41000", wantIP: "10.60.1.33", wantScheme: "http",
|
||||
},
|
||||
{
|
||||
name: "rightmost untrusted address wins over a client-supplied prefix",
|
||||
peer: "10.60.1.33:41000", xff: []string{"1.2.3.4, 203.0.113.7"}, xfp: []string{"https"},
|
||||
wantIP: "203.0.113.7", wantScheme: "https",
|
||||
},
|
||||
{
|
||||
name: "trusted proxies in the chain are skipped",
|
||||
peer: "10.60.1.33:41000", xff: []string{"1.2.3.4, 203.0.113.7, 10.60.2.5"}, xfp: []string{"https"},
|
||||
wantIP: "203.0.113.7", wantScheme: "https",
|
||||
},
|
||||
{
|
||||
name: "several header lines form one chain",
|
||||
peer: "10.60.1.33:41000", xff: []string{"1.2.3.4", "203.0.113.7, 10.60.2.5"},
|
||||
wantIP: "203.0.113.7", wantScheme: "http",
|
||||
},
|
||||
{
|
||||
name: "garbage left of the client is not examined",
|
||||
peer: "10.60.1.33:41000", xff: []string{"spoofed, 203.0.113.7"},
|
||||
wantIP: "203.0.113.7", wantScheme: "http",
|
||||
},
|
||||
{
|
||||
name: "all trusted: leftmost is the client",
|
||||
peer: "10.60.1.33:41000", xff: []string{"10.60.2.5, 10.60.2.6"},
|
||||
wantIP: "10.60.2.5", wantScheme: "http",
|
||||
},
|
||||
{
|
||||
name: "IPv6 client",
|
||||
peer: "10.60.1.33:41000", xff: []string{"2001:db8::7"}, xfp: []string{"HTTPS"},
|
||||
wantIP: "2001:db8::7", wantScheme: "https",
|
||||
},
|
||||
{
|
||||
name: "trusted peer may downgrade to http",
|
||||
peer: "10.60.1.33:41000", tls: true, xfp: []string{"http"},
|
||||
wantIP: "10.60.1.33", wantScheme: "http",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := request(tt.peer, tt.xff, tt.xfp)
|
||||
if tt.tls {
|
||||
r.TLS = &tls.ConnectionState{}
|
||||
}
|
||||
o, err := Resolve(r, trusted)
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve error = %v", err)
|
||||
}
|
||||
if o.ClientIP.String() != tt.wantIP || o.Scheme != tt.wantScheme {
|
||||
t.Errorf("Resolve = %s %s, want %s %s", o.ClientIP, o.Scheme, tt.wantIP, tt.wantScheme)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRejectsMalformedHeadersFromTrustedPeer(t *testing.T) {
|
||||
t.Parallel()
|
||||
trusted := mustTrusted(t, "10.60.1.33")
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
xff, xfp []string
|
||||
}{
|
||||
{"X-Forwarded-For not an IP", []string{"not-an-ip"}, nil},
|
||||
{"X-Forwarded-For with a port", []string{"203.0.113.7:5000"}, nil},
|
||||
{"X-Forwarded-For empty entry", []string{"203.0.113.7, "}, nil},
|
||||
{"X-Forwarded-For empty", []string{""}, nil},
|
||||
{"X-Forwarded-Proto unknown scheme", nil, []string{"ftp"}},
|
||||
{"X-Forwarded-Proto list", nil, []string{"https, http"}},
|
||||
{"X-Forwarded-Proto repeated", nil, []string{"https", "https"}},
|
||||
} {
|
||||
_, err := Resolve(request("10.60.1.33:41000", tt.xff, tt.xfp), trusted)
|
||||
if !errors.Is(err, ErrInvalidHeader) {
|
||||
t.Errorf("%s: Resolve error = %v, want ErrInvalidHeader", tt.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveBadPeerAddress(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := request("@", nil, nil)
|
||||
_, err := Resolve(r, nil)
|
||||
if err == nil || errors.Is(err, ErrInvalidHeader) {
|
||||
t.Errorf("Resolve error = %v, want a peer address error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func request(peer string, xff, xfp []string) *http.Request {
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
r.RemoteAddr = peer
|
||||
for _, v := range xff {
|
||||
r.Header.Add(HeaderFor, v)
|
||||
}
|
||||
for _, v := range xfp {
|
||||
r.Header.Add(HeaderProto, v)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func TestContextHelpers(t *testing.T) {
|
||||
t.Parallel()
|
||||
if _, ok := ClientIP(context.Background()); ok {
|
||||
t.Error("ClientIP ok on an unresolved context")
|
||||
}
|
||||
if _, ok := Scheme(context.Background()); ok {
|
||||
t.Error("Scheme ok on an unresolved context")
|
||||
}
|
||||
ctx := NewContext(context.Background(), Origin{ClientIP: netip.MustParseAddr("203.0.113.7"), Scheme: "https"})
|
||||
if ip, ok := ClientIP(ctx); !ok || ip.String() != "203.0.113.7" {
|
||||
t.Errorf("ClientIP = %s %v, want 203.0.113.7 true", ip, ok)
|
||||
}
|
||||
if s, ok := Scheme(ctx); !ok || s != "https" {
|
||||
t.Errorf("Scheme = %q %v, want https true", s, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogHandler(t *testing.T) {
|
||||
t.Parallel()
|
||||
var buf bytes.Buffer
|
||||
logger := slog.New(LogHandler(slog.NewTextHandler(&buf, nil))).With("component", "test")
|
||||
ctx := NewContext(context.Background(), Origin{ClientIP: netip.MustParseAddr("203.0.113.7"), Scheme: "https"})
|
||||
|
||||
logger.InfoContext(ctx, "resolved")
|
||||
logger.InfoContext(context.Background(), "unresolved")
|
||||
|
||||
lines := strings.Split(strings.TrimSpace(buf.String()), "\n")
|
||||
if len(lines) != 2 {
|
||||
t.Fatalf("logged %d lines, want 2: %q", len(lines), buf.String())
|
||||
}
|
||||
if !strings.Contains(lines[0], "client_ip=203.0.113.7") || !strings.Contains(lines[0], "component=test") {
|
||||
t.Errorf("resolved line = %q, want client_ip and the With attribute", lines[0])
|
||||
}
|
||||
if strings.Contains(lines[1], "client_ip") {
|
||||
t.Errorf("unresolved line = %q, want no client_ip", lines[1])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
|
||||
)
|
||||
|
||||
// CSRFHeader carries the session's CSRF token, which the client reads from
|
||||
// GET /api/v1/me.
|
||||
const CSRFHeader = "X-MealQuest-CSRF"
|
||||
|
||||
// requireCSRF passes GET requests to next; every other
|
||||
// request must carry CSRFHeader equal to its session's CSRF token or get 403
|
||||
// csrf. It runs behind requireSession, which puts the session in the context.
|
||||
func requireCSRF(logger *slog.Logger) func(http.Handler) http.Handler {
|
||||
forbidden := apiErrorHandler(logger, http.StatusForbidden, "csrf", "missing or invalid CSRF token")
|
||||
internal := apiErrorHandler(logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
s, ok := auth.SessionFrom(r.Context())
|
||||
if !ok || s.CSRFToken == "" {
|
||||
// An empty token would match a missing header.
|
||||
logger.ErrorContext(r.Context(), "csrf check without a session token", "session", ok)
|
||||
internal(w, r)
|
||||
return
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(r.Header.Get(CSRFHeader)), []byte(s.CSRFToken)) != 1 {
|
||||
forbidden(w, r)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
|
||||
)
|
||||
|
||||
const (
|
||||
csrfJSON = `{"error":{"code":"csrf","message":"missing or invalid CSRF token","retryable":false}}`
|
||||
methodNotAllowedJSON = `{"error":{"code":"method_not_allowed","message":"method not allowed","retryable":false}}`
|
||||
)
|
||||
|
||||
func TestCSRFThroughRouter(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
router := newTestRouter(t, "dev")
|
||||
for _, tc := range []struct {
|
||||
name, method, path string
|
||||
header []string // nil sends no CSRF header
|
||||
wantStatus int
|
||||
wantBody string
|
||||
}{
|
||||
{"POST without header", http.MethodPost, "/api/v1/me", nil, http.StatusForbidden, csrfJSON},
|
||||
{"POST with wrong header", http.MethodPost, "/api/v1/me", []string{"wrong-token"}, http.StatusForbidden, csrfJSON},
|
||||
{"POST with empty header", http.MethodPost, "/api/v1/me", []string{""}, http.StatusForbidden, csrfJSON},
|
||||
{"POST with token prefix", http.MethodPost, "/api/v1/me", []string{"csrf-token"}, http.StatusForbidden, csrfJSON},
|
||||
{"PATCH without header", http.MethodPatch, "/api/v1/settings/household", nil, http.StatusForbidden, csrfJSON},
|
||||
{"DELETE with wrong header", http.MethodDelete, "/api/v1/me", []string{"wrong-token"}, http.StatusForbidden, csrfJSON},
|
||||
// The right token passes the check and reaches the router, which has
|
||||
// no POST /api/v1/me.
|
||||
{"POST with right header", http.MethodPost, "/api/v1/me", []string{testSession.CSRFToken}, http.StatusMethodNotAllowed, methodNotAllowedJSON},
|
||||
{"PATCH with right header", http.MethodPatch, "/api/v1/settings/household", []string{testSession.CSRFToken}, http.StatusNotFound, notFoundJSON},
|
||||
{"GET without header", http.MethodGet, "/api/v1/me", nil, http.StatusOK, ""},
|
||||
// Only GET is exempt; HEAD and OPTIONS need the header too.
|
||||
{"HEAD without header", http.MethodHead, "/api/v1/me", nil, http.StatusForbidden, csrfJSON},
|
||||
{"OPTIONS without header", http.MethodOptions, "/api/v1/me", nil, http.StatusForbidden, csrfJSON},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
req := httptest.NewRequest(tc.method, tc.path, nil)
|
||||
req.AddCookie(validSessionCookie)
|
||||
if tc.header != nil {
|
||||
req.Header.Set(CSRFHeader, tc.header[0])
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != tc.wantStatus {
|
||||
t.Errorf("status = %d, want %d (body %s)", rec.Code, tc.wantStatus, rec.Body.String())
|
||||
}
|
||||
if tc.wantBody != "" && rec.Body.String() != tc.wantBody {
|
||||
t.Errorf("body = %s, want %s", rec.Body.String(), tc.wantBody)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSRFUnauthenticatedGets401(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/me", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
newTestRouter(t, "dev").ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusUnauthorized || rec.Body.String() != unauthenticatedJSON {
|
||||
t.Errorf("response = %d %s, want 401 %s", rec.Code, rec.Body.String(), unauthenticatedJSON)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireCSRF(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
method string
|
||||
session *auth.Session
|
||||
header string
|
||||
wantStatus int
|
||||
}{
|
||||
{"right header reaches handler", http.MethodPost, &testSession, testSession.CSRFToken, http.StatusNoContent},
|
||||
{"wrong header", http.MethodPut, &testSession, "nope", http.StatusForbidden},
|
||||
{"GET needs no header", http.MethodGet, &testSession, "", http.StatusNoContent},
|
||||
{"HEAD needs the header", http.MethodHead, &testSession, "", http.StatusForbidden},
|
||||
{"OPTIONS needs the header", http.MethodOptions, &testSession, "", http.StatusForbidden},
|
||||
{"no session in context", http.MethodPost, nil, "", http.StatusInternalServerError},
|
||||
{"session without token", http.MethodPost, &auth.Session{Subject: "user-123"}, "", http.StatusInternalServerError},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
called := false
|
||||
h := requireCSRF(logger)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
called = true
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
ctx := context.Background()
|
||||
if tc.session != nil {
|
||||
ctx = auth.WithSession(ctx, *tc.session)
|
||||
}
|
||||
req := httptest.NewRequestWithContext(ctx, tc.method, "/api/v1/anything", nil)
|
||||
if tc.header != "" {
|
||||
req.Header.Set(CSRFHeader, tc.header)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != tc.wantStatus {
|
||||
t.Errorf("status = %d, want %d", rec.Code, tc.wantStatus)
|
||||
}
|
||||
if called != (tc.wantStatus == http.StatusNoContent) {
|
||||
t.Errorf("handler called = %v with status %d", called, rec.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMe(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
|
||||
req.AddCookie(validSessionCookie)
|
||||
rec := httptest.NewRecorder()
|
||||
newTestRouter(t, "dev").ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body %s)", rec.Code, rec.Body.String())
|
||||
}
|
||||
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
|
||||
t.Errorf("Content-Type = %q, want application/json", ct)
|
||||
}
|
||||
if cc := rec.Header().Get("Cache-Control"); cc != "no-store" {
|
||||
t.Errorf("Cache-Control = %q, want no-store", cc)
|
||||
}
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode %s: %v", rec.Body.String(), err)
|
||||
}
|
||||
want := map[string]any{
|
||||
"name": "Home Cook",
|
||||
"email": "cook@example.com",
|
||||
"household": map[string]any{"label": "Our kitchen"},
|
||||
"csrfToken": "csrf-token-123",
|
||||
}
|
||||
gotJSON, _ := json.Marshal(got)
|
||||
wantJSON, _ := json.Marshal(want)
|
||||
if string(gotJSON) != string(wantJSON) {
|
||||
t.Errorf("body = %s, want %s", gotJSON, wantJSON)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeNeedsSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
newTestRouter(t, "dev").ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/me", nil))
|
||||
if rec.Code != http.StatusUnauthorized || rec.Body.String() != unauthenticatedJSON {
|
||||
t.Errorf("response = %d %s, want 401 %s", rec.Code, rec.Body.String(), unauthenticatedJSON)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||
)
|
||||
|
||||
// newDiscoveryProvider serves the OIDC discovery document auth.New needs;
|
||||
// login only redirects to its authorization endpoint.
|
||||
func newDiscoveryProvider(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
var srv *httptest.Server
|
||||
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/.well-known/openid-configuration" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"issuer": srv.URL,
|
||||
"authorization_endpoint": srv.URL + "/authorize",
|
||||
"token_endpoint": srv.URL + "/token",
|
||||
"jwks_uri": srv.URL + "/jwks",
|
||||
"userinfo_endpoint": srv.URL + "/userinfo",
|
||||
"end_session_endpoint": srv.URL + "/end-session/",
|
||||
"response_types_supported": []string{"code"},
|
||||
"subject_types_supported": []string{"public"},
|
||||
"id_token_signing_alg_values_supported": []string{"RS256"},
|
||||
})
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
// newLoginRouter wires the production router as cmd/server does: the real
|
||||
// auth flow and session manager, trusting Traefik only.
|
||||
func newLoginRouter(t *testing.T, issuer string) http.Handler {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
db, err := store.Open(ctx, t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := db.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
if err := store.Migrate(ctx, db.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sessions, err := auth.NewSessionManager(db, 720*time.Hour, 4320*time.Hour)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
logger := slog.New(forwarded.LogHandler(slog.NewTextHandler(io.Discard, nil)))
|
||||
flow, err := auth.New(ctx, auth.Config{
|
||||
Issuer: issuer,
|
||||
ClientID: "mealquest",
|
||||
ClientSecret: "client-s3cret",
|
||||
PublicURL: &url.URL{Scheme: "https", Host: "meals.example.com"},
|
||||
SecretKey: []byte("server-secret-key"),
|
||||
AllowedGroups: []string{"mealquest-admin"},
|
||||
}, sessions, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
trusted, err := forwarded.ParseTrusted(traefik)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := NewRouter(logger, "dev", trusted, flow, sessions,
|
||||
fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// TestLoginThroughRouter starts a login through the production router. The
|
||||
// redirect URI and the Secure login cookie come from MEALQUEST_PUBLIC_URL
|
||||
// whatever the direct peer: forwarded headers from an untrusted peer are
|
||||
// ignored but never fail sign-in.
|
||||
func TestLoginThroughRouter(t *testing.T) {
|
||||
t.Parallel()
|
||||
provider := newDiscoveryProvider(t)
|
||||
router := newLoginRouter(t, provider.URL)
|
||||
// Traefik reaches the service over plain http on its internal address;
|
||||
// the host headers must not leak into the redirect URI.
|
||||
headers := map[string]string{
|
||||
forwarded.HeaderFor: "203.0.113.7",
|
||||
forwarded.HeaderProto: "https",
|
||||
"X-Forwarded-Host": "evil.example.com",
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
peer string
|
||||
}{
|
||||
{"trusted proxy", traefik + ":41000"},
|
||||
{"untrusted peer", "172.17.0.1:5000"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, originRequest("http://mealquest:8080"+auth.LoginPath, tt.peer, headers))
|
||||
resp := rec.Result()
|
||||
if resp.StatusCode != http.StatusFound {
|
||||
t.Fatalf("login status = %d, want 302", 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, provider.URL+"/authorize"; got != want {
|
||||
t.Errorf("redirect to %s, want %s", got, want)
|
||||
}
|
||||
if got, want := loc.Query().Get("redirect_uri"), "https://meals.example.com/auth/callback"; got != want {
|
||||
t.Errorf("redirect_uri = %q, want %q from the public URL", got, want)
|
||||
}
|
||||
cookies := resp.Cookies()
|
||||
if len(cookies) != 1 || cookies[0].Name != "__Host-mealquest_login" || !cookies[0].Secure || !cookies[0].HttpOnly {
|
||||
t.Errorf("login cookies = %v, want one Secure, HttpOnly __Host-mealquest_login", cookies)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
|
||||
)
|
||||
|
||||
// householdLabel is the single shared household identity.
|
||||
const householdLabel = "Our kitchen"
|
||||
|
||||
type meResponse struct {
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Household householdInfo `json:"household"`
|
||||
CSRFToken string `json:"csrfToken"`
|
||||
}
|
||||
|
||||
type householdInfo struct {
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
// me answers GET /api/v1/me with the signed-in member, the household and the
|
||||
// session's CSRF token for CSRFHeader.
|
||||
func me(logger *slog.Logger) http.HandlerFunc {
|
||||
internal := apiErrorHandler(logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
s, ok := auth.SessionFrom(r.Context())
|
||||
if !ok {
|
||||
logger.ErrorContext(r.Context(), "GET /api/v1/me without a session")
|
||||
internal(w, r)
|
||||
return
|
||||
}
|
||||
body, err := json.Marshal(meResponse{
|
||||
Name: s.Name,
|
||||
Email: s.Email,
|
||||
Household: householdInfo{Label: householdLabel},
|
||||
CSRFToken: s.CSRFToken,
|
||||
})
|
||||
if err != nil {
|
||||
panic(err) // string fields cannot fail to marshal
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if _, err := w.Write(body); err != nil {
|
||||
logger.WarnContext(r.Context(), "write me response", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
|
||||
)
|
||||
|
||||
// resolveOrigin resolves every request's client address and scheme with
|
||||
// forwarded.Resolve and puts them in the context (read them with
|
||||
// forwarded.ClientIP and forwarded.Scheme). Forwarded headers are honoured
|
||||
// only from a peer in trusted. A malformed forwarded header from a trusted
|
||||
// peer gets 400 bad_request.
|
||||
func resolveOrigin(logger *slog.Logger, trusted forwarded.Trusted) func(http.Handler) http.Handler {
|
||||
badRequest := apiErrorHandler(logger, http.StatusBadRequest, "bad_request", "invalid forwarded headers")
|
||||
internal := apiErrorHandler(logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
o, err := forwarded.Resolve(r, trusted)
|
||||
switch {
|
||||
case errors.Is(err, forwarded.ErrInvalidHeader):
|
||||
logger.WarnContext(r.Context(), "rejected forwarded headers", "peer", r.RemoteAddr, "error", err)
|
||||
badRequest(w, r)
|
||||
case err != nil:
|
||||
logger.ErrorContext(r.Context(), "resolve request origin", "error", err)
|
||||
internal(w, r)
|
||||
default:
|
||||
next.ServeHTTP(w, r.WithContext(forwarded.NewContext(r.Context(), o)))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
|
||||
)
|
||||
|
||||
// traefik is the production proxy's address.
|
||||
const traefik = "10.60.1.33"
|
||||
|
||||
// probeAuth mounts GET /auth/probe, which reports the request's resolved
|
||||
// origin, in place of the real auth flow.
|
||||
type probeAuth struct{}
|
||||
|
||||
func (probeAuth) Mount(r chi.Router) {
|
||||
r.Get("/auth/probe", func(w http.ResponseWriter, r *http.Request) {
|
||||
ip, ipOK := forwarded.ClientIP(r.Context())
|
||||
scheme, schemeOK := forwarded.Scheme(r.Context())
|
||||
if !ipOK || !schemeOK {
|
||||
http.Error(w, "origin not resolved", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "%s %s", ip, scheme)
|
||||
})
|
||||
}
|
||||
|
||||
// newOriginRouter builds the production router trusting Traefik, logging
|
||||
// through forwarded.LogHandler as cmd/server does.
|
||||
func newOriginRouter(t *testing.T, logs io.Writer) http.Handler {
|
||||
t.Helper()
|
||||
trusted, err := forwarded.ParseTrusted(traefik)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(logs, nil)))
|
||||
r, err := NewRouter(logger, "dev", trusted, probeAuth{}, stubSessions{},
|
||||
fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func originRequest(target, peer string, headers map[string]string) *http.Request {
|
||||
req := httptest.NewRequest(http.MethodGet, target, nil)
|
||||
req.RemoteAddr = peer
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
func TestRouterResolvesOrigin(t *testing.T) {
|
||||
t.Parallel()
|
||||
spoofed := map[string]string{forwarded.HeaderFor: "203.0.113.7", forwarded.HeaderProto: "https"}
|
||||
tests := []struct {
|
||||
name string
|
||||
peer string
|
||||
want string
|
||||
}{
|
||||
{"trusted proxy's forwarded headers are honoured", traefik + ":41000", "203.0.113.7 https"},
|
||||
{"untrusted peer spoofing them is ignored", "198.51.100.9:5000", "198.51.100.9 http"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
rec := httptest.NewRecorder()
|
||||
newOriginRouter(t, io.Discard).ServeHTTP(rec, originRequest("/auth/probe", tt.peer, spoofed))
|
||||
if rec.Code != http.StatusOK || rec.Body.String() != tt.want {
|
||||
t.Errorf("probe = %d %q, want 200 %q", rec.Code, rec.Body.String(), tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterLogsResolvedClientIP(t *testing.T) {
|
||||
t.Parallel()
|
||||
spoofed := map[string]string{forwarded.HeaderFor: "203.0.113.7", forwarded.HeaderProto: "https"}
|
||||
tests := []struct {
|
||||
name string
|
||||
peer string
|
||||
want string
|
||||
}{
|
||||
{"trusted proxy", traefik + ":41000", "203.0.113.7"},
|
||||
{"untrusted peer spoofing", "198.51.100.9:5000", "198.51.100.9"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var logs bytes.Buffer
|
||||
// A failed session lookup is logged with the request's context.
|
||||
req := originRequest("/api/v1/me", tt.peer, spoofed)
|
||||
req.AddCookie(brokenSessionCookie)
|
||||
rec := httptest.NewRecorder()
|
||||
newOriginRouter(t, &logs).ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", rec.Code)
|
||||
}
|
||||
var line map[string]any
|
||||
if err := json.Unmarshal(logs.Bytes(), &line); err != nil {
|
||||
t.Fatalf("log %q: %v", logs.String(), err)
|
||||
}
|
||||
if line["msg"] != "look up session" || line[forwarded.LogAttr] != tt.want {
|
||||
t.Errorf("log = %v, want the session error with %s=%s", line, forwarded.LogAttr, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterRejectsMalformedForwardedHeaders(t *testing.T) {
|
||||
t.Parallel()
|
||||
const badRequestJSON = `{"error":{"code":"bad_request","message":"invalid forwarded headers","retryable":false}}`
|
||||
for _, headers := range []map[string]string{
|
||||
{forwarded.HeaderFor: "not-an-ip"},
|
||||
{forwarded.HeaderProto: "ftp"},
|
||||
} {
|
||||
var logs bytes.Buffer
|
||||
rec := httptest.NewRecorder()
|
||||
newOriginRouter(t, &logs).ServeHTTP(rec, originRequest("/health", traefik+":41000", headers))
|
||||
if rec.Code != http.StatusBadRequest || rec.Body.String() != badRequestJSON {
|
||||
t.Errorf("%v: response = %d %s, want 400 %s", headers, rec.Code, rec.Body.String(), badRequestJSON)
|
||||
}
|
||||
if !bytes.Contains(logs.Bytes(), []byte("rejected forwarded headers")) {
|
||||
t.Errorf("%v: logs = %q, want the rejection logged", headers, logs.String())
|
||||
}
|
||||
|
||||
// The same headers from an untrusted peer are not examined at all.
|
||||
rec = httptest.NewRecorder()
|
||||
newOriginRouter(t, io.Discard).ServeHTTP(rec, originRequest("/health", "198.51.100.9:5000", headers))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("%v from an untrusted peer: status = %d, want 200", headers, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
|
||||
)
|
||||
|
||||
@@ -21,10 +22,13 @@ 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) {
|
||||
// NewRouter returns the HTTP handler for the service. Every request first
|
||||
// has its client address and scheme resolved, honouring forwarded headers
|
||||
// only from a peer in trusted (see resolveOrigin). version is reported by
|
||||
// GET /health; auth mounts the /auth routes; sessions guards /api/v1 and
|
||||
// /media; assets is the web build served for every other GET. /health, /auth
|
||||
// and the web build are public. Requests to /api/v1 other than GET also need the session's CSRF token in CSRFHeader.
|
||||
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, 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")
|
||||
|
||||
@@ -34,18 +38,31 @@ func NewRouter(logger *slog.Logger, version string, auth RouteMounter, assets fs
|
||||
}
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Use(resolveOrigin(logger, trusted))
|
||||
r.MethodNotAllowed(methodNotAllowed)
|
||||
r.NotFound(notFound)
|
||||
|
||||
r.Get("/health", health(logger, version))
|
||||
auth.Mount(r)
|
||||
// The session middleware wraps the whole /api/v1 and /media subrouters
|
||||
// rather than being added with Use: chi skips a subrouter's middleware
|
||||
// when it has no routes, which would leave it unguarded.
|
||||
signedIn := requireSession(logger, sessions)
|
||||
csrf := requireCSRF(logger)
|
||||
apiV1, media := chi.NewRouter(), chi.NewRouter()
|
||||
for _, sub := range []*chi.Mux{apiV1, media} {
|
||||
sub.MethodNotAllowed(methodNotAllowed)
|
||||
sub.NotFound(notFound)
|
||||
}
|
||||
apiV1.Get("/me", me(logger))
|
||||
r.Route("/api", func(r chi.Router) {
|
||||
r.MethodNotAllowed(methodNotAllowed)
|
||||
r.NotFound(notFound)
|
||||
r.Mount("/v1", signedIn(csrf(apiV1)))
|
||||
})
|
||||
// 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.
|
||||
r.Mount("/media", signedIn(media))
|
||||
// Catch-all for the PWA. Unknown /auth paths reach the frontend handler,
|
||||
// which passes them to notFound rather than the SPA fallback.
|
||||
r.Method(http.MethodGet, "/*", web)
|
||||
r.Method(http.MethodHead, "/*", web)
|
||||
return r, nil
|
||||
|
||||
@@ -27,7 +27,7 @@ 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, nil, stubAuth{}, stubSessions{}, assets)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -126,6 +126,8 @@ func TestReservedPathsNotFound(t *testing.T) {
|
||||
}{
|
||||
{http.MethodGet, "/api"},
|
||||
{http.MethodGet, "/api/unknown"},
|
||||
{http.MethodGet, "/api/v1/unknown"},
|
||||
{http.MethodPost, "/api/v1/unknown"},
|
||||
{http.MethodPost, "/api/unknown"},
|
||||
{http.MethodGet, "/auth/unknown"},
|
||||
{http.MethodGet, "/media/photo.jpg"},
|
||||
@@ -137,8 +139,13 @@ func TestReservedPathsNotFound(t *testing.T) {
|
||||
t.Run(tt.method+" "+tt.path, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Signed in with the CSRF token, so /api/v1 and /media reach
|
||||
// their routers.
|
||||
req := httptest.NewRequest(tt.method, tt.path, nil)
|
||||
req.AddCookie(validSessionCookie)
|
||||
req.Header.Set(CSRFHeader, testSession.CSRFToken)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, httptest.NewRequest(tt.method, tt.path, nil))
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("status = %d, want 404", rec.Code)
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
|
||||
)
|
||||
|
||||
// SessionLookup finds the live session a request carries; it returns
|
||||
// auth.ErrNoSession when there is none. *auth.SessionManager implements it.
|
||||
type SessionLookup interface {
|
||||
SessionFor(r *http.Request) (auth.Session, error)
|
||||
}
|
||||
|
||||
// requireSession is the RequireSession middleware. It passes requests with a
|
||||
// live session to next with the session in the context (read it with
|
||||
// auth.SessionFrom or auth.ActorFrom); others get 401 unauthenticated.
|
||||
func requireSession(logger *slog.Logger, sessions SessionLookup) func(http.Handler) http.Handler {
|
||||
unauthenticated := apiErrorHandler(logger, http.StatusUnauthorized, "unauthenticated", "sign in required")
|
||||
internal := apiErrorHandler(logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
s, err := sessions.SessionFor(r)
|
||||
switch {
|
||||
case errors.Is(err, auth.ErrNoSession):
|
||||
unauthenticated(w, r)
|
||||
case err != nil:
|
||||
logger.ErrorContext(r.Context(), "look up session", "error", err)
|
||||
internal(w, r)
|
||||
default:
|
||||
next.ServeHTTP(w, r.WithContext(auth.WithSession(r.Context(), s)))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
|
||||
)
|
||||
|
||||
const (
|
||||
unauthenticatedJSON = `{"error":{"code":"unauthenticated","message":"sign in required","retryable":false}}`
|
||||
internalJSON = `{"error":{"code":"internal","message":"internal error","retryable":false}}`
|
||||
)
|
||||
|
||||
var (
|
||||
validSessionCookie = &http.Cookie{Name: auth.SessionCookieName, Value: "valid"}
|
||||
brokenSessionCookie = &http.Cookie{Name: auth.SessionCookieName, Value: "broken"}
|
||||
testSession = auth.Session{
|
||||
Subject: "user-123",
|
||||
Email: "cook@example.com",
|
||||
Name: "Home Cook",
|
||||
Groups: []string{"mealquest-admin"},
|
||||
CSRFToken: "csrf-token-123",
|
||||
}
|
||||
)
|
||||
|
||||
// stubSessions knows one valid session cookie; the broken cookie fails the
|
||||
// lookup as a database error would.
|
||||
type stubSessions struct{}
|
||||
|
||||
func (stubSessions) SessionFor(r *http.Request) (auth.Session, error) {
|
||||
c, err := r.Cookie(auth.SessionCookieName)
|
||||
switch {
|
||||
case err != nil:
|
||||
return auth.Session{}, auth.ErrNoSession
|
||||
case c.Value == validSessionCookie.Value:
|
||||
return testSession, nil
|
||||
case c.Value == brokenSessionCookie.Value:
|
||||
return auth.Session{}, errors.New("database down")
|
||||
default:
|
||||
return auth.Session{}, auth.ErrNoSession
|
||||
}
|
||||
}
|
||||
|
||||
func TestProtectedPathsRequireSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
router := newTestRouter(t, "dev")
|
||||
for _, tc := range []struct {
|
||||
method, path string
|
||||
cookie *http.Cookie
|
||||
}{
|
||||
{http.MethodGet, "/api/v1/settings/household", nil},
|
||||
{http.MethodPatch, "/api/v1/settings/household", nil},
|
||||
{http.MethodGet, "/api/v1", nil},
|
||||
{http.MethodGet, "/media/photo.jpg", nil},
|
||||
{http.MethodGet, "/api/v1/settings/household", &http.Cookie{Name: auth.SessionCookieName, Value: "unknown"}},
|
||||
} {
|
||||
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
req := httptest.NewRequest(tc.method, tc.path, nil)
|
||||
if tc.cookie != nil {
|
||||
req.AddCookie(tc.cookie)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want 401", rec.Code)
|
||||
}
|
||||
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
|
||||
t.Errorf("Content-Type = %q, want application/json", ct)
|
||||
}
|
||||
if got := rec.Body.String(); got != unauthenticatedJSON {
|
||||
t.Errorf("body = %s, want %s", got, unauthenticatedJSON)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicPathsNeedNoSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
router := newTestRouter(t, "dev")
|
||||
for _, path := range []string{"/health", "/auth/login", "/", "/assets/index-AbC123.js", "/quests/42"} {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("GET %s without a cookie = %d, want 200", path, rec.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionLookupFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/settings/household", nil)
|
||||
req.AddCookie(brokenSessionCookie)
|
||||
rec := httptest.NewRecorder()
|
||||
newTestRouter(t, "dev").ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusInternalServerError || rec.Body.String() != internalJSON {
|
||||
t.Errorf("response = %d %s, want 500 %s", rec.Code, rec.Body.String(), internalJSON)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireSessionAttachesActor(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var got auth.Actor
|
||||
var ok bool
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
got, ok = auth.ActorFrom(r.Context())
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
h := requireSession(slog.New(slog.NewTextHandler(io.Discard, nil)), stubSessions{})(next)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/settings/household", nil)
|
||||
req.AddCookie(validSessionCookie)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want the handler's 204", rec.Code)
|
||||
}
|
||||
if !ok || got.Subject != "user-123" || got.Email != "cook@example.com" || got.Name != "Home Cook" ||
|
||||
!slices.Equal(got.Groups, []string{"mealquest-admin"}) {
|
||||
t.Errorf("actor = %+v (ok=%v), want the session's household member", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireSessionStopsUnauthenticated(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
called := false
|
||||
next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true })
|
||||
h := requireSession(slog.New(slog.NewTextHandler(io.Discard, nil)), stubSessions{})(next)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/settings/household", nil))
|
||||
|
||||
if called || rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("handler called = %v, status = %d; want not called and 401", called, rec.Code)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user