Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2fe767471 | ||
|
|
52664ad64b | ||
|
|
60c3538967 | ||
|
|
1dfba7b9db |
@@ -40,10 +40,6 @@ export MEALQUEST_OIDC_ALLOWED_GROUPS=mealquest-admin
|
|||||||
# and always MEALQUEST_SESSION_MAX after sign-in (default 4320h).
|
# and always MEALQUEST_SESSION_MAX after sign-in (default 4320h).
|
||||||
# export MEALQUEST_SESSION_IDLE=720h
|
# export MEALQUEST_SESSION_IDLE=720h
|
||||||
# export MEALQUEST_SESSION_MAX=4320h
|
# 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
|
make dev
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -51,8 +47,7 @@ make dev
|
|||||||
(which proxies `/api`, `/auth` and `/media` to `:8080`). Open
|
(which proxies `/api`, `/auth` and `/media` to `:8080`). Open
|
||||||
http://localhost:5173. Configuration is environment-only and fails fast:
|
http://localhost:5173. Configuration is environment-only and fails fast:
|
||||||
every variable above is required except the commented-out session
|
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
|
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
|
endpoints the issuer advertises must be `https://`; plain `http://` is
|
||||||
accepted only for `localhost`, `127.0.0.1` and `[::1]`.
|
accepted only for `localhost`, `127.0.0.1` and `[::1]`.
|
||||||
|
|||||||
+7
-10
@@ -15,7 +15,6 @@ import (
|
|||||||
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
|
"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/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/frontend"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/server"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/server"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||||
@@ -32,7 +31,7 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(os.Stdout, nil)))
|
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||||
if err := run(cfg, logger); err != nil {
|
if err := run(cfg, logger); err != nil {
|
||||||
logger.Error("server stopped", "error", err)
|
logger.Error("server stopped", "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -58,18 +57,17 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
authFlow, err := auth.New(ctx, auth.Config{
|
authFlow, err := auth.New(ctx, auth.Config{
|
||||||
Issuer: cfg.OIDCIssuer.String(),
|
Issuer: cfg.OIDCIssuer.String(),
|
||||||
ClientID: cfg.OIDCClientID,
|
ClientID: cfg.OIDCClientID,
|
||||||
ClientSecret: cfg.OIDCClientSecret.Reveal(),
|
ClientSecret: cfg.OIDCClientSecret.Reveal(),
|
||||||
PublicURL: cfg.PublicURL,
|
PublicURL: cfg.PublicURL,
|
||||||
SecretKey: []byte(cfg.SecretKey.Reveal()),
|
SecretKey: []byte(cfg.SecretKey.Reveal()),
|
||||||
AllowedGroups: cfg.OIDCAllowedGroups,
|
|
||||||
}, sessions, logger)
|
}, sessions, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, frontend.Assets())
|
router, err := server.NewRouter(logger, version, authFlow, frontend.Assets())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -127,7 +125,6 @@ func logListening(logger *slog.Logger, cfg config.Config) {
|
|||||||
logger.Info("listening",
|
logger.Info("listening",
|
||||||
"addr", cfg.HTTPAddr,
|
"addr", cfg.HTTPAddr,
|
||||||
"public_origin", cfg.PublicOrigin(),
|
"public_origin", cfg.PublicOrigin(),
|
||||||
"trusted_proxy", cfg.TrustedProxy,
|
|
||||||
"data_dir", cfg.DataDir,
|
"data_dir", cfg.DataDir,
|
||||||
"version", version,
|
"version", version,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -29,15 +29,6 @@ docker compose project "mealquest" — 10.60.1.14
|
|||||||
One Go service serves the API, the auth flow, media and the PWA. Its SQLite
|
One Go service serves the API, the auth flow, media and the PWA. Its SQLite
|
||||||
database is replicated by Litestream to the NAS.
|
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
|
## Repository layout
|
||||||
|
|
||||||
| Path | Purpose |
|
| Path | Purpose |
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
package auth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"slices"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Actor is the signed-in household member a request acts for. Handlers
|
|
||||||
// record Actor.Subject as updated_by on edits.
|
|
||||||
type Actor struct {
|
|
||||||
Subject string
|
|
||||||
Email string
|
|
||||||
Name string
|
|
||||||
Groups []string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Actor returns the household member s belongs to.
|
|
||||||
func (s Session) Actor() Actor {
|
|
||||||
return Actor{Subject: s.Subject, Email: s.Email, Name: s.Name, Groups: slices.Clone(s.Groups)}
|
|
||||||
}
|
|
||||||
|
|
||||||
type 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
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
package auth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"slices"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestActorContext(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
if _, ok := ActorFrom(context.Background()); ok {
|
|
||||||
t.Error("ActorFrom found an actor in an empty context")
|
|
||||||
}
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+5
-119
@@ -12,8 +12,6 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"slices"
|
|
||||||
"sync/atomic"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/coreos/go-oidc/v3/oidc"
|
"github.com/coreos/go-oidc/v3/oidc"
|
||||||
@@ -67,20 +65,15 @@ type Config struct {
|
|||||||
PublicURL *url.URL
|
PublicURL *url.URL
|
||||||
// SecretKey is the server key the login cookie's signing key derives from.
|
// SecretKey is the server key the login cookie's signing key derives from.
|
||||||
SecretKey []byte
|
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.
|
// Auth serves the login and callback routes.
|
||||||
type Auth struct {
|
type Auth struct {
|
||||||
oauth oauth2.Config
|
oauth oauth2.Config
|
||||||
verifier *oidc.IDTokenVerifier
|
verifier *oidc.IDTokenVerifier
|
||||||
provider *oidc.Provider
|
|
||||||
client *http.Client
|
client *http.Client
|
||||||
cookie loginCookie
|
cookie loginCookie
|
||||||
sessions Sessions
|
sessions Sessions
|
||||||
allowed []string
|
|
||||||
// endSessionURL is the provider's end_session_endpoint; logout ends
|
// endSessionURL is the provider's end_session_endpoint; logout ends
|
||||||
// there so the provider's own session ends too.
|
// there so the provider's own session ends too.
|
||||||
endSessionURL string
|
endSessionURL string
|
||||||
@@ -99,8 +92,6 @@ func New(ctx context.Context, cfg Config, sessions Sessions, logger *slog.Logger
|
|||||||
return nil, errors.New("auth: secret key is required")
|
return nil, errors.New("auth: secret key is required")
|
||||||
case sessions == nil:
|
case sessions == nil:
|
||||||
return nil, errors.New("auth: session layer is required")
|
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 {
|
if err := checkEndpoint("issuer", cfg.Issuer); err != nil {
|
||||||
@@ -108,12 +99,9 @@ func New(ctx context.Context, cfg Config, sessions Sessions, logger *slog.Logger
|
|||||||
}
|
}
|
||||||
|
|
||||||
client := &http.Client{Timeout: providerTimeout, CheckRedirect: checkRedirect}
|
client := &http.Client{Timeout: providerTimeout, CheckRedirect: checkRedirect}
|
||||||
discovery := *client
|
provider, err := oidc.NewProvider(oidc.ClientContext(ctx, client), cfg.Issuer)
|
||||||
recorder := &statusRecorder{next: http.DefaultTransport}
|
|
||||||
discovery.Transport = recorder
|
|
||||||
provider, err := oidc.NewProvider(oidc.ClientContext(ctx, &discovery), cfg.Issuer)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, discoveryError(err, int(recorder.status.Load()))
|
return nil, fmt.Errorf("auth: OIDC discovery: %w", err)
|
||||||
}
|
}
|
||||||
endpoint := provider.Endpoint()
|
endpoint := provider.Endpoint()
|
||||||
var discovered struct {
|
var discovered struct {
|
||||||
@@ -130,9 +118,6 @@ func New(ctx context.Context, cfg Config, sessions Sessions, logger *slog.Logger
|
|||||||
{"token endpoint", endpoint.TokenURL},
|
{"token endpoint", endpoint.TokenURL},
|
||||||
{"JWKS endpoint", discovered.JWKSURI},
|
{"JWKS endpoint", discovered.JWKSURI},
|
||||||
{"end session endpoint", discovered.EndSessionURI},
|
{"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 {
|
if err := checkEndpoint(e.name, e.raw); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -153,9 +138,7 @@ func New(ctx context.Context, cfg Config, sessions Sessions, logger *slog.Logger
|
|||||||
ClientID: cfg.ClientID,
|
ClientID: cfg.ClientID,
|
||||||
SupportedSigningAlgs: []string{oidc.RS256},
|
SupportedSigningAlgs: []string{oidc.RS256},
|
||||||
}),
|
}),
|
||||||
provider: provider,
|
|
||||||
client: client,
|
client: client,
|
||||||
allowed: slices.Clone(cfg.AllowedGroups),
|
|
||||||
cookie: newLoginCookie(cfg.SecretKey),
|
cookie: newLoginCookie(cfg.SecretKey),
|
||||||
sessions: sessions,
|
sessions: sessions,
|
||||||
endSessionURL: discovered.EndSessionURI,
|
endSessionURL: discovered.EndSessionURI,
|
||||||
@@ -163,32 +146,6 @@ func New(ctx context.Context, cfg Config, sessions Sessions, logger *slog.Logger
|
|||||||
}, nil
|
}, 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
|
// checkEndpoint applies secureurl.Check to the provider URL raw, naming the
|
||||||
// endpoint in the error.
|
// endpoint in the error.
|
||||||
func checkEndpoint(name, raw string) error {
|
func checkEndpoint(name, raw string) error {
|
||||||
@@ -261,9 +218,7 @@ func (a *Auth) login(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// callback completes the flow: it checks state against the login cookie,
|
// 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) {
|
func (a *Auth) callback(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
@@ -301,22 +256,6 @@ func (a *Auth) callback(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "sign-in failed", http.StatusUnauthorized)
|
http.Error(w, "sign-in failed", http.StatusUnauthorized)
|
||||||
return
|
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 {
|
if err := a.sessions.StartSession(w, r, claims); err != nil {
|
||||||
a.logger.ErrorContext(ctx, "start session", "error", err)
|
a.logger.ErrorContext(ctx, "start session", "error", err)
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
@@ -339,8 +278,7 @@ func (a *Auth) logout(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// verify exchanges code for tokens and returns the verified ID token claims.
|
// verify exchanges code for tokens and returns the verified ID token claims.
|
||||||
// The verifier enforces RS256, issuer, audience and expiry; nonce is checked
|
// The verifier enforces RS256, issuer, audience and expiry; nonce is checked
|
||||||
// here. When the ID token has no groups claim, Groups comes from userinfo;
|
// here.
|
||||||
// it stays nil when neither has one.
|
|
||||||
func (a *Auth) verify(ctx context.Context, code string, state loginState) (Claims, error) {
|
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,
|
token, err := a.oauth.Exchange(context.WithValue(ctx, oauth2.HTTPClient, a.client), code,
|
||||||
oauth2.VerifierOption(state.Verifier))
|
oauth2.VerifierOption(state.Verifier))
|
||||||
@@ -353,7 +291,7 @@ func (a *Auth) verify(ctx context.Context, code string, state loginState) (Claim
|
|||||||
}
|
}
|
||||||
idToken, err := a.verifier.Verify(ctx, raw)
|
idToken, err := a.verifier.Verify(ctx, raw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Claims{}, verifyError(err)
|
return Claims{}, fmt.Errorf("verify ID token: %w", err)
|
||||||
}
|
}
|
||||||
if subtle.ConstantTimeCompare([]byte(idToken.Nonce), []byte(state.Nonce)) != 1 {
|
if subtle.ConstantTimeCompare([]byte(idToken.Nonce), []byte(state.Nonce)) != 1 {
|
||||||
return Claims{}, errors.New("ID token nonce mismatch")
|
return Claims{}, errors.New("ID token nonce mismatch")
|
||||||
@@ -365,61 +303,9 @@ func (a *Auth) verify(ctx context.Context, code string, state loginState) (Claim
|
|||||||
if claims.Subject == "" {
|
if claims.Subject == "" {
|
||||||
return Claims{}, errors.New("ID token has no 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
|
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")
|
|
||||||
}
|
|
||||||
|
|
||||||
// verifyError reduces a failed ID token verification to a fixed category.
|
|
||||||
// go-oidc's error text can carry the JWKS endpoint's response body or
|
|
||||||
// token-supplied values, so only the expiry of an expired token is kept.
|
|
||||||
func verifyError(err error) error {
|
|
||||||
var expired *oidc.TokenExpiredError
|
|
||||||
if errors.As(err, &expired) {
|
|
||||||
return fmt.Errorf("verify ID token: token expired at %s", expired.Expiry.UTC().Format(time.RFC3339))
|
|
||||||
}
|
|
||||||
return errors.New("verify ID token: token or provider key set rejected")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
// exchangeError reduces a failed code exchange to a fixed category and safe
|
||||||
// metadata. The oauth2 error text can carry the token endpoint's response
|
// metadata. The oauth2 error text can carry the token endpoint's response
|
||||||
// body or error_description, which may echo credentials, so it is dropped.
|
// body or error_description, which may echo credentials, so it is dropped.
|
||||||
|
|||||||
+31
-192
@@ -11,7 +11,6 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"maps"
|
"maps"
|
||||||
@@ -37,9 +36,6 @@ const (
|
|||||||
|
|
||||||
var testPublicURL = &url.URL{Scheme: "https", Host: "meals.example.com"}
|
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
|
// fakeProvider is an OIDC provider serving discovery, JWKS and a token
|
||||||
// endpoint. Each issued code maps to the ID token the token endpoint returns.
|
// endpoint. Each issued code maps to the ID token the token endpoint returns.
|
||||||
type fakeProvider struct {
|
type fakeProvider struct {
|
||||||
@@ -54,14 +50,6 @@ type fakeProvider struct {
|
|||||||
// tokenReply, when set, is returned by the token endpoint instead of a
|
// tokenReply, when set, is returned by the token endpoint instead of a
|
||||||
// token.
|
// token.
|
||||||
tokenReply *tokenReply
|
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
|
|
||||||
// jwksReply, when set, is returned by the JWKS endpoint instead.
|
|
||||||
jwksReply *tokenReply
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type tokenReply struct {
|
type tokenReply struct {
|
||||||
@@ -85,7 +73,6 @@ func newFakeProvider(t *testing.T) *fakeProvider {
|
|||||||
mux.HandleFunc("GET /.well-known/openid-configuration", p.discovery)
|
mux.HandleFunc("GET /.well-known/openid-configuration", p.discovery)
|
||||||
mux.HandleFunc("GET /jwks", p.jwks)
|
mux.HandleFunc("GET /jwks", p.jwks)
|
||||||
mux.HandleFunc("POST /token", p.token)
|
mux.HandleFunc("POST /token", p.token)
|
||||||
mux.HandleFunc("GET /userinfo", p.userinfoEndpoint)
|
|
||||||
p.srv = httptest.NewServer(mux)
|
p.srv = httptest.NewServer(mux)
|
||||||
t.Cleanup(p.srv.Close)
|
t.Cleanup(p.srv.Close)
|
||||||
return p
|
return p
|
||||||
@@ -99,7 +86,6 @@ func (p *fakeProvider) discovery(w http.ResponseWriter, _ *http.Request) {
|
|||||||
"authorization_endpoint": p.issuer() + "/authorize",
|
"authorization_endpoint": p.issuer() + "/authorize",
|
||||||
"token_endpoint": p.issuer() + "/token",
|
"token_endpoint": p.issuer() + "/token",
|
||||||
"jwks_uri": p.issuer() + "/jwks",
|
"jwks_uri": p.issuer() + "/jwks",
|
||||||
"userinfo_endpoint": p.issuer() + "/userinfo",
|
|
||||||
"end_session_endpoint": p.issuer() + "/end-session/",
|
"end_session_endpoint": p.issuer() + "/end-session/",
|
||||||
"response_types_supported": []string{"code"},
|
"response_types_supported": []string{"code"},
|
||||||
"subject_types_supported": []string{"public"},
|
"subject_types_supported": []string{"public"},
|
||||||
@@ -112,15 +98,6 @@ func (p *fakeProvider) discovery(w http.ResponseWriter, _ *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *fakeProvider) jwks(w http.ResponseWriter, _ *http.Request) {
|
func (p *fakeProvider) jwks(w http.ResponseWriter, _ *http.Request) {
|
||||||
p.mu.Lock()
|
|
||||||
reply := p.jwksReply
|
|
||||||
p.mu.Unlock()
|
|
||||||
if reply != nil {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(reply.status)
|
|
||||||
io.WriteString(w, reply.body)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, map[string]any{"keys": []map[string]string{{
|
writeJSON(w, map[string]any{"keys": []map[string]string{{
|
||||||
"kty": "RSA",
|
"kty": "RSA",
|
||||||
"kid": testKID,
|
"kid": testKID,
|
||||||
@@ -171,41 +148,6 @@ 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
|
// authorize stands in for the user signing in at the provider: it records a
|
||||||
// code bound to the PKCE challenge and returns it.
|
// code bound to the PKCE challenge and returns it.
|
||||||
func (p *fakeProvider) authorize(challenge, idToken string) string {
|
func (p *fakeProvider) authorize(challenge, idToken string) string {
|
||||||
@@ -319,7 +261,6 @@ func (m *memorySessions) started() []Claims {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type harness struct {
|
type harness struct {
|
||||||
auth *Auth
|
|
||||||
provider *fakeProvider
|
provider *fakeProvider
|
||||||
sessions *memorySessions
|
sessions *memorySessions
|
||||||
router http.Handler
|
router http.Handler
|
||||||
@@ -350,19 +291,18 @@ func newHarness(t *testing.T) *harness {
|
|||||||
sessions := &memorySessions{}
|
sessions := &memorySessions{}
|
||||||
logs := &syncBuffer{}
|
logs := &syncBuffer{}
|
||||||
a, err := New(context.Background(), Config{
|
a, err := New(context.Background(), Config{
|
||||||
Issuer: p.issuer(),
|
Issuer: p.issuer(),
|
||||||
ClientID: testClientID,
|
ClientID: testClientID,
|
||||||
ClientSecret: testClientSecret,
|
ClientSecret: testClientSecret,
|
||||||
PublicURL: testPublicURL,
|
PublicURL: testPublicURL,
|
||||||
SecretKey: []byte("server-secret-key"),
|
SecretKey: []byte("server-secret-key"),
|
||||||
AllowedGroups: testAllowedGroups,
|
|
||||||
}, sessions, slog.New(slog.NewTextHandler(logs, nil)))
|
}, sessions, slog.New(slog.NewTextHandler(logs, nil)))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
a.Mount(r)
|
a.Mount(r)
|
||||||
return &harness{auth: a, provider: p, sessions: sessions, router: r, logs: logs}
|
return &harness{provider: p, sessions: sessions, router: r, logs: logs}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *harness) get(target string, cookies ...*http.Cookie) *http.Response {
|
func (h *harness) get(target string, cookies ...*http.Cookie) *http.Response {
|
||||||
@@ -740,66 +680,6 @@ func TestCallbackExchangeFailureDoesNotLogProviderResponse(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCallbackKeySetFailureDoesNotLogProviderResponse(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
const sentinel = "sentinel-jwks-body-5c1e"
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
reply tokenReply
|
|
||||||
}{
|
|
||||||
{"error response", tokenReply{http.StatusInternalServerError, sentinel}},
|
|
||||||
{"malformed success", tokenReply{http.StatusOK, `{"keys":"` + sentinel + `"`}},
|
|
||||||
}
|
|
||||||
for _, tc := range tests {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
h := newHarness(t)
|
|
||||||
h.provider.mu.Lock()
|
|
||||||
h.provider.jwksReply = &tc.reply
|
|
||||||
h.provider.mu.Unlock()
|
|
||||||
|
|
||||||
// The returned error, checked through verify directly.
|
|
||||||
l := h.login(t, LoginPath)
|
|
||||||
req := httptest.NewRequest(http.MethodGet, CallbackPath, nil)
|
|
||||||
req.AddCookie(l.cookie)
|
|
||||||
state, err := h.auth.cookie.read(req, time.Now())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
code := h.provider.authorize(l.query.Get("code_challenge"), h.provider.signRS256(h.provider.claims(l.query.Get("nonce"))))
|
|
||||||
_, err = h.auth.verify(context.Background(), code, state)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("verify succeeded with an unusable key set")
|
|
||||||
}
|
|
||||||
if strings.Contains(err.Error(), sentinel) {
|
|
||||||
t.Errorf("verify error contains the JWKS response: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The logs and response, through the callback.
|
|
||||||
l = h.login(t, LoginPath)
|
|
||||||
resp := h.callback(l, l.query.Get("state"), h.provider.signRS256(h.provider.claims(l.query.Get("nonce"))))
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusUnauthorized {
|
|
||||||
t.Errorf("callback status = %d, want 401", resp.StatusCode)
|
|
||||||
}
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
if strings.Contains(string(body), sentinel) {
|
|
||||||
t.Errorf("response contains the JWKS response: %s", body)
|
|
||||||
}
|
|
||||||
logs := h.logs.String()
|
|
||||||
if strings.Contains(logs, sentinel) {
|
|
||||||
t.Errorf("logs contain the JWKS response: %s", logs)
|
|
||||||
}
|
|
||||||
if want := "token or provider key set rejected"; !strings.Contains(logs, want) {
|
|
||||||
t.Errorf("logs = %q, want %q", logs, want)
|
|
||||||
}
|
|
||||||
if len(h.sessions.started()) != 0 {
|
|
||||||
t.Error("a session started")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoginCookieExpires(t *testing.T) {
|
func TestLoginCookieExpires(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
c := newLoginCookie([]byte("server-secret-key"))
|
c := newLoginCookie([]byte("server-secret-key"))
|
||||||
@@ -850,65 +730,28 @@ func TestNewFailsWhenDiscoveryFails(t *testing.T) {
|
|||||||
srv := httptest.NewServer(http.NotFoundHandler())
|
srv := httptest.NewServer(http.NotFoundHandler())
|
||||||
t.Cleanup(srv.Close)
|
t.Cleanup(srv.Close)
|
||||||
_, err := New(context.Background(), Config{
|
_, err := New(context.Background(), Config{
|
||||||
Issuer: srv.URL,
|
Issuer: srv.URL,
|
||||||
ClientID: testClientID,
|
ClientID: testClientID,
|
||||||
ClientSecret: testClientSecret,
|
ClientSecret: testClientSecret,
|
||||||
PublicURL: testPublicURL,
|
PublicURL: testPublicURL,
|
||||||
SecretKey: []byte("k"),
|
SecretKey: []byte("k"),
|
||||||
AllowedGroups: testAllowedGroups,
|
|
||||||
}, &memorySessions{}, slog.New(slog.DiscardHandler))
|
}, &memorySessions{}, slog.New(slog.DiscardHandler))
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("New succeeded without a discovery document")
|
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) {
|
func TestNewFailsOnIssuerMismatch(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
p := newFakeProvider(t)
|
p := newFakeProvider(t)
|
||||||
// Discovery trims the trailing slash, so this fetches the provider's
|
// Discovery trims the trailing slash, so this fetches the provider's
|
||||||
// document, which reports the issuer without it.
|
// document, which reports the issuer without it.
|
||||||
_, err := New(context.Background(), Config{
|
_, err := New(context.Background(), Config{
|
||||||
Issuer: p.issuer() + "/",
|
Issuer: p.issuer() + "/",
|
||||||
ClientID: testClientID,
|
ClientID: testClientID,
|
||||||
ClientSecret: testClientSecret,
|
ClientSecret: testClientSecret,
|
||||||
PublicURL: testPublicURL,
|
PublicURL: testPublicURL,
|
||||||
SecretKey: []byte("k"),
|
SecretKey: []byte("k"),
|
||||||
AllowedGroups: testAllowedGroups,
|
|
||||||
}, &memorySessions{}, slog.New(slog.DiscardHandler))
|
}, &memorySessions{}, slog.New(slog.DiscardHandler))
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("New accepted a provider reporting a different issuer")
|
t.Fatal("New accepted a provider reporting a different issuer")
|
||||||
@@ -929,12 +772,11 @@ func TestNewRejectsRemoteHTTPIssuer(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
for _, issuer := range []string{"http://auth.example.com/application/o/mealquest/", "http://192.0.2.10"} {
|
for _, issuer := range []string{"http://auth.example.com/application/o/mealquest/", "http://192.0.2.10"} {
|
||||||
_, err := New(context.Background(), Config{
|
_, err := New(context.Background(), Config{
|
||||||
Issuer: issuer,
|
Issuer: issuer,
|
||||||
ClientID: testClientID,
|
ClientID: testClientID,
|
||||||
ClientSecret: testClientSecret,
|
ClientSecret: testClientSecret,
|
||||||
PublicURL: testPublicURL,
|
PublicURL: testPublicURL,
|
||||||
SecretKey: []byte("k"),
|
SecretKey: []byte("k"),
|
||||||
AllowedGroups: testAllowedGroups,
|
|
||||||
}, &memorySessions{}, slog.New(slog.DiscardHandler))
|
}, &memorySessions{}, slog.New(slog.DiscardHandler))
|
||||||
if err == nil || !strings.Contains(err.Error(), "issuer") {
|
if err == nil || !strings.Contains(err.Error(), "issuer") {
|
||||||
t.Errorf("New(issuer %q) error = %v, want issuer rejected", issuer, err)
|
t.Errorf("New(issuer %q) error = %v, want issuer rejected", issuer, err)
|
||||||
@@ -951,7 +793,6 @@ func TestNewRejectsRemoteHTTPEndpoint(t *testing.T) {
|
|||||||
{"token_endpoint", "token endpoint"},
|
{"token_endpoint", "token endpoint"},
|
||||||
{"jwks_uri", "JWKS endpoint"},
|
{"jwks_uri", "JWKS endpoint"},
|
||||||
{"end_session_endpoint", "end session endpoint"},
|
{"end_session_endpoint", "end session endpoint"},
|
||||||
{"userinfo_endpoint", "userinfo endpoint"},
|
|
||||||
}
|
}
|
||||||
for _, tc := range tests {
|
for _, tc := range tests {
|
||||||
t.Run(tc.field, func(t *testing.T) {
|
t.Run(tc.field, func(t *testing.T) {
|
||||||
@@ -959,12 +800,11 @@ func TestNewRejectsRemoteHTTPEndpoint(t *testing.T) {
|
|||||||
p := newFakeProvider(t)
|
p := newFakeProvider(t)
|
||||||
p.discoveryOverrides = map[string]any{tc.field: "http://auth.example.com/" + tc.field}
|
p.discoveryOverrides = map[string]any{tc.field: "http://auth.example.com/" + tc.field}
|
||||||
_, err := New(context.Background(), Config{
|
_, err := New(context.Background(), Config{
|
||||||
Issuer: p.issuer(),
|
Issuer: p.issuer(),
|
||||||
ClientID: testClientID,
|
ClientID: testClientID,
|
||||||
ClientSecret: testClientSecret,
|
ClientSecret: testClientSecret,
|
||||||
PublicURL: testPublicURL,
|
PublicURL: testPublicURL,
|
||||||
SecretKey: []byte("k"),
|
SecretKey: []byte("k"),
|
||||||
AllowedGroups: testAllowedGroups,
|
|
||||||
}, &memorySessions{}, slog.New(slog.DiscardHandler))
|
}, &memorySessions{}, slog.New(slog.DiscardHandler))
|
||||||
if err == nil || !strings.Contains(err.Error(), tc.name) {
|
if err == nil || !strings.Contains(err.Error(), tc.name) {
|
||||||
t.Errorf("New error = %v, want %s rejected", err, tc.name)
|
t.Errorf("New error = %v, want %s rejected", err, tc.name)
|
||||||
@@ -1000,12 +840,11 @@ func TestCallbackRefusesTokenRedirectToHTTP(t *testing.T) {
|
|||||||
sessions := &memorySessions{}
|
sessions := &memorySessions{}
|
||||||
logs := &syncBuffer{}
|
logs := &syncBuffer{}
|
||||||
a, err := New(context.Background(), Config{
|
a, err := New(context.Background(), Config{
|
||||||
Issuer: p.issuer(),
|
Issuer: p.issuer(),
|
||||||
ClientID: testClientID,
|
ClientID: testClientID,
|
||||||
ClientSecret: testClientSecret,
|
ClientSecret: testClientSecret,
|
||||||
PublicURL: testPublicURL,
|
PublicURL: testPublicURL,
|
||||||
SecretKey: []byte("server-secret-key"),
|
SecretKey: []byte("server-secret-key"),
|
||||||
AllowedGroups: testAllowedGroups,
|
|
||||||
}, sessions, slog.New(slog.NewTextHandler(logs, nil)))
|
}, sessions, slog.New(slog.NewTextHandler(logs, nil)))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
@@ -1,264 +0,0 @@
|
|||||||
package auth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"slices"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// signInWith completes a login whose ID token has claims edited by edit.
|
|
||||||
func (h *harness) signInWith(t *testing.T, edit func(claims map[string]any)) *http.Response {
|
|
||||||
t.Helper()
|
|
||||||
l := h.login(t, LoginPath)
|
|
||||||
c := h.provider.claims(l.query.Get("nonce"))
|
|
||||||
edit(c)
|
|
||||||
return h.callback(l, l.query.Get("state"), h.provider.signRS256(c))
|
|
||||||
}
|
|
||||||
|
|
||||||
// assertRefused checks resp is the refused-sign-in page and starts no
|
|
||||||
// session.
|
|
||||||
func assertRefused(t *testing.T, resp *http.Response) {
|
|
||||||
t.Helper()
|
|
||||||
if resp.StatusCode != http.StatusForbidden {
|
|
||||||
t.Fatalf("callback status = %d, want 403", resp.StatusCode)
|
|
||||||
}
|
|
||||||
if ct := resp.Header.Get("Content-Type"); ct != "text/html; charset=utf-8" {
|
|
||||||
t.Errorf("Content-Type = %q, want text/html; charset=utf-8", ct)
|
|
||||||
}
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
for _, want := range []string{
|
|
||||||
"This kitchen is for the household only",
|
|
||||||
`<form method="post" action="/auth/logout">`,
|
|
||||||
"Sign out",
|
|
||||||
} {
|
|
||||||
if !strings.Contains(string(body), want) {
|
|
||||||
t.Errorf("refused page lacks %q:\n%s", want, body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, c := range resp.Cookies() {
|
|
||||||
// Clearing a session cookie (negative Max-Age) is expected.
|
|
||||||
if (c.Name == SessionCookieName || c.Name == "test_session") && c.MaxAge >= 0 {
|
|
||||||
t.Errorf("refused sign-in set session cookie %s", c.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assertLoginCookieCleared(t, resp)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGroupGateMember(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
// Any one allowed group is enough, alongside groups that are not allowed.
|
|
||||||
h := newHarness(t)
|
|
||||||
resp := h.signInWith(t, func(c map[string]any) { c["groups"] = []string{"guests", "mealquest-family"} })
|
|
||||||
if resp.StatusCode != http.StatusSeeOther {
|
|
||||||
t.Fatalf("callback status = %d, want 303", resp.StatusCode)
|
|
||||||
}
|
|
||||||
if started := h.sessions.started(); len(started) != 1 || !slices.Equal(started[0].Groups, []string{"guests", "mealquest-family"}) {
|
|
||||||
t.Errorf("sessions started = %+v, want one with the ID token groups", started)
|
|
||||||
}
|
|
||||||
if n := h.provider.userinfoRequests(); n != 0 {
|
|
||||||
t.Errorf("userinfo requested %d times, want 0 when the ID token has groups", n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGroupGateRefused(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
groups any // ID token groups claim; nil removes it
|
|
||||||
userinfo map[string]any
|
|
||||||
}{
|
|
||||||
{name: "non-member", groups: []string{"guests", "MEALQUEST-ADMIN"}},
|
|
||||||
{name: "empty groups claim", groups: []string{}},
|
|
||||||
{name: "groups claim missing from ID token and userinfo", groups: nil},
|
|
||||||
{
|
|
||||||
name: "non-member in userinfo",
|
|
||||||
groups: nil,
|
|
||||||
userinfo: map[string]any{"sub": "user-123", "groups": []string{"guests"}},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, tc := range tests {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
h := newHarness(t)
|
|
||||||
if tc.userinfo != nil {
|
|
||||||
h.provider.setUserinfo(tc.userinfo)
|
|
||||||
}
|
|
||||||
resp := h.signInWith(t, func(c map[string]any) {
|
|
||||||
if tc.groups == nil {
|
|
||||||
delete(c, "groups")
|
|
||||||
} else {
|
|
||||||
c["groups"] = tc.groups
|
|
||||||
}
|
|
||||||
})
|
|
||||||
assertRefused(t, resp)
|
|
||||||
if started := h.sessions.started(); len(started) != 0 {
|
|
||||||
t.Errorf("sessions started = %+v, want none", started)
|
|
||||||
}
|
|
||||||
if !strings.Contains(h.logs.String(), "sign-in refused") {
|
|
||||||
t.Errorf("refusal not logged:\n%s", h.logs.String())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGroupGateUsesUserinfoWhenIDTokenHasNoGroups(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
h := newHarness(t)
|
|
||||||
h.provider.setUserinfo(map[string]any{"sub": "user-123", "groups": []string{"mealquest-admin"}})
|
|
||||||
resp := h.signInWith(t, func(c map[string]any) { delete(c, "groups") })
|
|
||||||
if resp.StatusCode != http.StatusSeeOther {
|
|
||||||
t.Fatalf("callback status = %d, want 303", resp.StatusCode)
|
|
||||||
}
|
|
||||||
if started := h.sessions.started(); len(started) != 1 || !slices.Equal(started[0].Groups, []string{"mealquest-admin"}) {
|
|
||||||
t.Errorf("sessions started = %+v, want one with the userinfo groups", started)
|
|
||||||
}
|
|
||||||
if n := h.provider.userinfoRequests(); n != 1 {
|
|
||||||
t.Errorf("userinfo requested %d times, want 1", n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGroupGateUserinfoFailures(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
userinfo map[string]any
|
|
||||||
}{
|
|
||||||
{"subject mismatch", map[string]any{"sub": "someone-else", "groups": []string{"mealquest-admin"}}},
|
|
||||||
{"malformed groups", map[string]any{"sub": "user-123", "groups": "mealquest-admin"}},
|
|
||||||
}
|
|
||||||
for _, tc := range tests {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
h := newHarness(t)
|
|
||||||
h.provider.setUserinfo(tc.userinfo)
|
|
||||||
resp := h.signInWith(t, func(c map[string]any) { delete(c, "groups") })
|
|
||||||
if resp.StatusCode != http.StatusUnauthorized {
|
|
||||||
t.Errorf("callback status = %d, want 401", resp.StatusCode)
|
|
||||||
}
|
|
||||||
if started := h.sessions.started(); len(started) != 0 {
|
|
||||||
t.Errorf("sessions started = %+v, want none", started)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A failed userinfo request fails the sign-in without logging the
|
|
||||||
// endpoint's response, which may echo credentials.
|
|
||||||
func TestGroupGateUserinfoErrorNotLogged(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
h := newHarness(t)
|
|
||||||
h.provider.mu.Lock()
|
|
||||||
h.provider.userinfoReply = &tokenReply{status: http.StatusInternalServerError, body: `{"error":"leaked-s3cret"}`}
|
|
||||||
h.provider.mu.Unlock()
|
|
||||||
resp := h.signInWith(t, func(c map[string]any) { delete(c, "groups") })
|
|
||||||
if resp.StatusCode != http.StatusUnauthorized {
|
|
||||||
t.Errorf("callback status = %d, want 401", resp.StatusCode)
|
|
||||||
}
|
|
||||||
if started := h.sessions.started(); len(started) != 0 {
|
|
||||||
t.Errorf("sessions started = %+v, want none", started)
|
|
||||||
}
|
|
||||||
logs := h.logs.String()
|
|
||||||
if !strings.Contains(logs, "userinfo") || strings.Contains(logs, "leaked-s3cret") {
|
|
||||||
t.Errorf("logs = %s, want the userinfo failure without the response body", logs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Membership is checked at every login: after removal from the group, the
|
|
||||||
// next login is refused and starts no session.
|
|
||||||
func TestGroupGateRemovedMemberRefusedAtNextLogin(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
h := newSessionHarness(t)
|
|
||||||
h.signIn(t)
|
|
||||||
if n := len(h.rows(t)); n != 1 {
|
|
||||||
t.Fatalf("sessions after first login = %d, want 1", n)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp := h.signInWith(t, func(c map[string]any) { c["groups"] = []string{"former-household"} })
|
|
||||||
assertRefused(t, resp)
|
|
||||||
if n := len(h.rows(t)); n != 1 {
|
|
||||||
t.Errorf("sessions after refused login = %d, want still 1", n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A refused sign-in from a browser that still holds a session ends that
|
|
||||||
// session and clears its cookie.
|
|
||||||
func TestGroupGateRefusalEndsExistingSession(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
h := newSessionHarness(t)
|
|
||||||
_, cookie := h.signIn(t)
|
|
||||||
|
|
||||||
l := h.login(t, LoginPath)
|
|
||||||
c := h.provider.claims(l.query.Get("nonce"))
|
|
||||||
c["groups"] = []string{"former-household"}
|
|
||||||
code := h.provider.authorize(l.query.Get("code_challenge"), h.provider.signRS256(c))
|
|
||||||
q := url.Values{"code": {code}, "state": {l.query.Get("state")}}
|
|
||||||
resp := h.get(CallbackPath+"?"+q.Encode(), l.cookie, cookie)
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusForbidden {
|
|
||||||
t.Fatalf("callback status = %d, want 403", resp.StatusCode)
|
|
||||||
}
|
|
||||||
if n := len(h.rows(t)); n != 0 {
|
|
||||||
t.Errorf("sessions after refused login = %d, want 0", n)
|
|
||||||
}
|
|
||||||
if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) {
|
|
||||||
t.Errorf("old session lookup error = %v, want ErrNoSession", err)
|
|
||||||
}
|
|
||||||
cleared := false
|
|
||||||
for _, c := range resp.Cookies() {
|
|
||||||
cleared = cleared || (c.Name == SessionCookieName && c.MaxAge < 0)
|
|
||||||
}
|
|
||||||
if !cleared {
|
|
||||||
t.Error("refused sign-in did not clear the session cookie")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGroupGateRefusalEndSessionError(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
h := newHarness(t)
|
|
||||||
h.sessions.err = errors.New("database down")
|
|
||||||
resp := h.signInWith(t, func(c map[string]any) { c["groups"] = []string{"guests"} })
|
|
||||||
if resp.StatusCode != http.StatusInternalServerError {
|
|
||||||
t.Errorf("callback status = %d, want 500", resp.StatusCode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewRequiresAllowedGroups(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
p := newFakeProvider(t)
|
|
||||||
_, err := New(context.Background(), Config{
|
|
||||||
Issuer: p.issuer(),
|
|
||||||
ClientID: testClientID,
|
|
||||||
ClientSecret: testClientSecret,
|
|
||||||
PublicURL: testPublicURL,
|
|
||||||
SecretKey: []byte("k"),
|
|
||||||
}, &memorySessions{}, slog.New(slog.DiscardHandler))
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "allowed group") {
|
|
||||||
t.Errorf("New error = %v, want missing allowed groups named", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewRequiresUserinfoEndpoint(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
p := newFakeProvider(t)
|
|
||||||
p.discoveryOverrides = map[string]any{"userinfo_endpoint": nil}
|
|
||||||
_, err := New(context.Background(), Config{
|
|
||||||
Issuer: p.issuer(),
|
|
||||||
ClientID: testClientID,
|
|
||||||
ClientSecret: testClientSecret,
|
|
||||||
PublicURL: testPublicURL,
|
|
||||||
SecretKey: []byte("k"),
|
|
||||||
AllowedGroups: testAllowedGroups,
|
|
||||||
}, &memorySessions{}, slog.New(slog.DiscardHandler))
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "userinfo endpoint") {
|
|
||||||
t.Errorf("New error = %v, want the missing userinfo endpoint named", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
package auth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
)
|
|
||||||
|
|
||||||
// refusedPage is shown when a sign-in succeeds at the provider but the
|
|
||||||
// account is not in the household's group. Signing out ends the provider
|
|
||||||
// session too, so another account can sign in.
|
|
||||||
const refusedPage = `<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>MealQuest</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<main>
|
|
||||||
<h1>This kitchen is for the household only</h1>
|
|
||||||
<p>The account you signed in with is not part of our kitchen.</p>
|
|
||||||
<form method="post" action="` + LogoutPath + `">
|
|
||||||
<button type="submit">Sign out</button>
|
|
||||||
</form>
|
|
||||||
</main>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
`
|
|
||||||
|
|
||||||
// refuse writes the refused-sign-in page with 403.
|
|
||||||
func (a *Auth) refuse(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
w.WriteHeader(http.StatusForbidden)
|
|
||||||
if _, err := io.WriteString(w, refusedPage); err != nil {
|
|
||||||
a.logger.WarnContext(r.Context(), "write refused sign-in page", "error", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -62,12 +62,11 @@ func newSessionHarness(t *testing.T) *sessionHarness {
|
|||||||
p := newFakeProvider(t)
|
p := newFakeProvider(t)
|
||||||
logs := &syncBuffer{}
|
logs := &syncBuffer{}
|
||||||
a, err := New(ctx, Config{
|
a, err := New(ctx, Config{
|
||||||
Issuer: p.issuer(),
|
Issuer: p.issuer(),
|
||||||
ClientID: testClientID,
|
ClientID: testClientID,
|
||||||
ClientSecret: testClientSecret,
|
ClientSecret: testClientSecret,
|
||||||
PublicURL: testPublicURL,
|
PublicURL: testPublicURL,
|
||||||
SecretKey: []byte("server-secret-key"),
|
SecretKey: []byte("server-secret-key"),
|
||||||
AllowedGroups: testAllowedGroups,
|
|
||||||
}, sessions, slog.New(slog.NewTextHandler(logs, nil)))
|
}, sessions, slog.New(slog.NewTextHandler(logs, nil)))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -491,12 +490,11 @@ func TestNewRequiresEndSessionEndpoint(t *testing.T) {
|
|||||||
p := newFakeProvider(t)
|
p := newFakeProvider(t)
|
||||||
p.discoveryOverrides = map[string]any{"end_session_endpoint": nil}
|
p.discoveryOverrides = map[string]any{"end_session_endpoint": nil}
|
||||||
_, err := New(context.Background(), Config{
|
_, err := New(context.Background(), Config{
|
||||||
Issuer: p.issuer(),
|
Issuer: p.issuer(),
|
||||||
ClientID: testClientID,
|
ClientID: testClientID,
|
||||||
ClientSecret: testClientSecret,
|
ClientSecret: testClientSecret,
|
||||||
PublicURL: testPublicURL,
|
PublicURL: testPublicURL,
|
||||||
SecretKey: []byte("k"),
|
SecretKey: []byte("k"),
|
||||||
AllowedGroups: testAllowedGroups,
|
|
||||||
}, &memorySessions{}, slog.New(slog.DiscardHandler))
|
}, &memorySessions{}, slog.New(slog.DiscardHandler))
|
||||||
if err == nil || !strings.Contains(err.Error(), "end session endpoint") {
|
if err == nil || !strings.Contains(err.Error(), "end session endpoint") {
|
||||||
t.Errorf("New error = %v, want the missing end session endpoint named", err)
|
t.Errorf("New error = %v, want the missing end session endpoint named", err)
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secureurl"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secureurl"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -31,8 +30,6 @@ const (
|
|||||||
|
|
||||||
EnvSessionIdle = "MEALQUEST_SESSION_IDLE"
|
EnvSessionIdle = "MEALQUEST_SESSION_IDLE"
|
||||||
EnvSessionMax = "MEALQUEST_SESSION_MAX"
|
EnvSessionMax = "MEALQUEST_SESSION_MAX"
|
||||||
|
|
||||||
EnvTrustedProxy = "MEALQUEST_TRUSTED_PROXY"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Session lifetime defaults (D-004): long enough that the kitchen tablet
|
// Session lifetime defaults (D-004): long enough that the kitchen tablet
|
||||||
@@ -61,10 +58,6 @@ type Config struct {
|
|||||||
SessionIdle time.Duration
|
SessionIdle time.Duration
|
||||||
// SessionMax ends every session this long after sign-in, used or not.
|
// SessionMax ends every session this long after sign-in, used or not.
|
||||||
SessionMax time.Duration
|
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
|
// PublicOrigin returns the scheme and host of PublicURL, the only parts of it
|
||||||
@@ -95,8 +88,6 @@ func load(lookup func(string) (string, bool)) (Config, error) {
|
|||||||
|
|
||||||
SessionIdle: r.sessionDuration(EnvSessionIdle, DefaultSessionIdle),
|
SessionIdle: r.sessionDuration(EnvSessionIdle, DefaultSessionIdle),
|
||||||
SessionMax: r.sessionDuration(EnvSessionMax, DefaultSessionMax),
|
SessionMax: r.sessionDuration(EnvSessionMax, DefaultSessionMax),
|
||||||
|
|
||||||
TrustedProxy: r.trustedProxy(EnvTrustedProxy),
|
|
||||||
}
|
}
|
||||||
if cfg.SessionIdle > 0 && cfg.SessionMax > 0 && cfg.SessionMax < cfg.SessionIdle {
|
if cfg.SessionIdle > 0 && cfg.SessionMax > 0 && cfg.SessionMax < cfg.SessionIdle {
|
||||||
r.fail(EnvSessionMax, "must be at least %s (%s)", EnvSessionIdle, cfg.SessionIdle)
|
r.fail(EnvSessionMax, "must be at least %s (%s)", EnvSessionIdle, cfg.SessionIdle)
|
||||||
@@ -186,18 +177,6 @@ func (r *reader) list(name string) []string {
|
|||||||
return items
|
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 {
|
func (r *reader) listenAddr(name string) string {
|
||||||
v, ok := r.required(name)
|
v, ok := r.required(name)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package config
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/netip"
|
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -12,8 +11,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestLoad(t *testing.T) {
|
func TestLoad(t *testing.T) {
|
||||||
@@ -296,48 +293,6 @@ func TestLoad(t *testing.T) {
|
|||||||
env: with(map[string]string{EnvSessionMax: "719h"}),
|
env: with(map[string]string{EnvSessionMax: "719h"}),
|
||||||
badVars: []string{EnvSessionMax},
|
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",
|
name: "several invalid values are all named",
|
||||||
env: with(map[string]string{
|
env: with(map[string]string{
|
||||||
@@ -447,13 +402,8 @@ func TestLoadValues(t *testing.T) {
|
|||||||
t.Errorf("SessionIdle, SessionMax = %v, %v, want defaults 720h, 4320h", cfg.SessionIdle, cfg.SessionMax)
|
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[EnvSessionIdle] = "12h"
|
||||||
env[EnvSessionMax] = "36h"
|
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 })
|
cfg, err = load(func(k string) (string, bool) { v, ok := env[k]; return v, ok })
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("load() error = %v", err)
|
t.Fatalf("load() error = %v", err)
|
||||||
@@ -461,10 +411,6 @@ func TestLoadValues(t *testing.T) {
|
|||||||
if cfg.SessionIdle != 12*time.Hour || cfg.SessionMax != 36*time.Hour {
|
if cfg.SessionIdle != 12*time.Hour || cfg.SessionMax != 36*time.Hour {
|
||||||
t.Errorf("SessionIdle, SessionMax = %v, %v, want 12h, 36h", cfg.SessionIdle, cfg.SessionMax)
|
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) {
|
func TestSecretRedacted(t *testing.T) {
|
||||||
|
|||||||
@@ -1,221 +0,0 @@
|
|||||||
// 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)}
|
|
||||||
}
|
|
||||||
@@ -1,260 +0,0 @@
|
|||||||
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])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
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)))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
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,7 +9,6 @@ import (
|
|||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
"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/frontend"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -22,13 +21,10 @@ type RouteMounter interface {
|
|||||||
Mount(r chi.Router)
|
Mount(r chi.Router)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRouter returns the HTTP handler for the service. Every request first
|
// NewRouter returns the HTTP handler for the service. version is reported by
|
||||||
// has its client address and scheme resolved, honouring forwarded headers
|
// GET /health; auth mounts the /auth routes; assets is the web build served
|
||||||
// only from a peer in trusted (see resolveOrigin). version is reported by
|
// for every other GET.
|
||||||
// GET /health; auth mounts the /auth routes; sessions guards /api/v1 and
|
func NewRouter(logger *slog.Logger, version string, auth RouteMounter, assets fs.FS) (http.Handler, error) {
|
||||||
// /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")
|
notFound := apiErrorHandler(logger, http.StatusNotFound, "not_found", "not found")
|
||||||
methodNotAllowed := apiErrorHandler(logger, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
|
methodNotAllowed := apiErrorHandler(logger, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
|
||||||
|
|
||||||
@@ -38,31 +34,18 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
|||||||
}
|
}
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Use(resolveOrigin(logger, trusted))
|
|
||||||
r.MethodNotAllowed(methodNotAllowed)
|
r.MethodNotAllowed(methodNotAllowed)
|
||||||
r.NotFound(notFound)
|
r.NotFound(notFound)
|
||||||
|
|
||||||
r.Get("/health", health(logger, version))
|
r.Get("/health", health(logger, version))
|
||||||
auth.Mount(r)
|
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.Route("/api", func(r chi.Router) {
|
||||||
r.MethodNotAllowed(methodNotAllowed)
|
r.MethodNotAllowed(methodNotAllowed)
|
||||||
r.NotFound(notFound)
|
r.NotFound(notFound)
|
||||||
r.Mount("/v1", signedIn(csrf(apiV1)))
|
|
||||||
})
|
})
|
||||||
r.Mount("/media", signedIn(media))
|
// Catch-all for the PWA. Unknown /auth paths and /media (no routes yet)
|
||||||
// Catch-all for the PWA. Unknown /auth paths reach the frontend handler,
|
// reach the frontend handler, which passes them to notFound rather than
|
||||||
// which passes them to notFound rather than the SPA fallback.
|
// the SPA fallback.
|
||||||
r.Method(http.MethodGet, "/*", web)
|
r.Method(http.MethodGet, "/*", web)
|
||||||
r.Method(http.MethodHead, "/*", web)
|
r.Method(http.MethodHead, "/*", web)
|
||||||
return r, nil
|
return r, nil
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ func newTestRouter(t *testing.T, version string) http.Handler {
|
|||||||
"index.html": {Data: []byte(fixtureIndex)},
|
"index.html": {Data: []byte(fixtureIndex)},
|
||||||
"assets/index-AbC123.js": {Data: []byte(fixtureAsset)},
|
"assets/index-AbC123.js": {Data: []byte(fixtureAsset)},
|
||||||
}
|
}
|
||||||
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, assets)
|
r, err := NewRouter(logger, version, stubAuth{}, assets)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -126,8 +126,6 @@ func TestReservedPathsNotFound(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{http.MethodGet, "/api"},
|
{http.MethodGet, "/api"},
|
||||||
{http.MethodGet, "/api/unknown"},
|
{http.MethodGet, "/api/unknown"},
|
||||||
{http.MethodGet, "/api/v1/unknown"},
|
|
||||||
{http.MethodPost, "/api/v1/unknown"},
|
|
||||||
{http.MethodPost, "/api/unknown"},
|
{http.MethodPost, "/api/unknown"},
|
||||||
{http.MethodGet, "/auth/unknown"},
|
{http.MethodGet, "/auth/unknown"},
|
||||||
{http.MethodGet, "/media/photo.jpg"},
|
{http.MethodGet, "/media/photo.jpg"},
|
||||||
@@ -139,13 +137,8 @@ func TestReservedPathsNotFound(t *testing.T) {
|
|||||||
t.Run(tt.method+" "+tt.path, func(t *testing.T) {
|
t.Run(tt.method+" "+tt.path, func(t *testing.T) {
|
||||||
t.Parallel()
|
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()
|
rec := httptest.NewRecorder()
|
||||||
router.ServeHTTP(rec, req)
|
router.ServeHTTP(rec, httptest.NewRequest(tt.method, tt.path, nil))
|
||||||
|
|
||||||
if rec.Code != http.StatusNotFound {
|
if rec.Code != http.StatusNotFound {
|
||||||
t.Errorf("status = %d, want 404", rec.Code)
|
t.Errorf("status = %d, want 404", rec.Code)
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
|
|
||||||
)
|
|
||||||
|
|
||||||
// SessionLookup finds the live session a request carries; it returns
|
|
||||||
// auth.ErrNoSession when there is none. *auth.SessionManager implements it.
|
|
||||||
type SessionLookup interface {
|
|
||||||
SessionFor(r *http.Request) (auth.Session, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// requireSession is the RequireSession middleware. It passes requests with a
|
|
||||||
// live session to next with the session 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)))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"slices"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
unauthenticatedJSON = `{"error":{"code":"unauthenticated","message":"sign in required","retryable":false}}`
|
|
||||||
internalJSON = `{"error":{"code":"internal","message":"internal error","retryable":false}}`
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
validSessionCookie = &http.Cookie{Name: auth.SessionCookieName, Value: "valid"}
|
|
||||||
brokenSessionCookie = &http.Cookie{Name: auth.SessionCookieName, Value: "broken"}
|
|
||||||
testSession = auth.Session{
|
|
||||||
Subject: "user-123",
|
|
||||||
Email: "cook@example.com",
|
|
||||||
Name: "Home Cook",
|
|
||||||
Groups: []string{"mealquest-admin"},
|
|
||||||
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