Compare commits

...
Author SHA1 Message Date
dev_agentandClaude Opus 5.5 c824942484 End any existing session when a sign-in is refused
CI / ci (pull_request) Successful in 1m24s
Independent review Codex gpt-6-sol high: APPROVED (0 findings)
CI / ci (push) Successful in 1m28s
A browser that still holds a session and signs in as an account outside
the household's group got the 403 page but kept its old session. The
refusal now ends that session and clears its cookie first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 10:25:59 -04:00
dev_agentandClaude Opus 5.5 f9f10c3a53 Gate sign-in on household group and require a session for /api/v1 and /media
The callback now starts a session only when the verified groups claim
contains a group from MEALQUEST_OIDC_ALLOWED_GROUPS. When the ID token has
no groups claim, groups come from the userinfo endpoint, whose subject must
match the ID token. A refused sign-in gets a 403 HTML page ("This kitchen is
for the household only") with a sign-out button, and no session. The check
runs on every login, so an account removed from the group is refused at its
next one. auth.New requires at least one allowed group and a discovered
userinfo endpoint that passes the endpoint rule.

The RequireSession middleware wraps the /api/v1 and /media subrouters: no
live session gets 401 {"error":{"code":"unauthenticated",...}}, a lookup
failure 500, and a live session reaches the handler with the actor in the
context. It wraps the subrouters instead of using chi's Use because chi
skips middleware on a subrouter with no routes. /health, /auth and the web
build stay public.

auth.WithActor and auth.ActorFrom are the one way to read the signed-in
household member; handlers record Actor.Subject as updated_by.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 10:24:35 -04:00
dev_agent f0bdd0ef40 Feature 03 task 2: Server-side sessions, cookie and logout (#19)
CI / ci (push) Successful in 1m24s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-03 10:20:14 -04:00
16 changed files with 1680 additions and 73 deletions
+7 -2
View File
@@ -36,14 +36,19 @@ export MEALQUEST_OIDC_ISSUER=https://auth.example.com/application/o/mealquest/
export MEALQUEST_OIDC_CLIENT_ID=mealquest
export MEALQUEST_OIDC_CLIENT_SECRET_FILE="$HOME/.mealquest-dev/oidc_client_secret"
export MEALQUEST_OIDC_ALLOWED_GROUPS=mealquest-admin
# Optional: sessions end after MEALQUEST_SESSION_IDLE unused (default 720h)
# and always MEALQUEST_SESSION_MAX after sign-in (default 4320h).
# export MEALQUEST_SESSION_IDLE=720h
# export MEALQUEST_SESSION_MAX=4320h
make dev
```
`make dev` runs the Go server on `:8080` and the Vite dev server on `:5173`
(which proxies `/api`, `/auth` and `/media` to `:8080`). Open
http://localhost:5173. Configuration is environment-only and fails fast:
every variable above is required, and secrets are read only from files named
by `*_FILE` variables. `MEALQUEST_PUBLIC_URL`, `MEALQUEST_OIDC_ISSUER` and the
every variable above is required except the commented-out session
lifetimes (when set they must be positive, with MAX at least IDLE), and
secrets are read only from files named by `*_FILE` variables. `MEALQUEST_PUBLIC_URL`, `MEALQUEST_OIDC_ISSUER` and the
endpoints the issuer advertises must be `https://`; plain `http://` is
accepted only for `localhost`, `127.0.0.1` and `[::1]`.
+12 -15
View File
@@ -52,18 +52,23 @@ func run(cfg config.Config, logger *slog.Logger) error {
}
}()
sessions, err := auth.NewSessionManager(db, cfg.SessionIdle, cfg.SessionMax)
if err != nil {
return err
}
authFlow, err := auth.New(ctx, auth.Config{
Issuer: cfg.OIDCIssuer.String(),
ClientID: cfg.OIDCClientID,
ClientSecret: cfg.OIDCClientSecret.Reveal(),
PublicURL: cfg.PublicURL,
SecretKey: []byte(cfg.SecretKey.Reveal()),
}, sessionsPending{}, logger)
Issuer: cfg.OIDCIssuer.String(),
ClientID: cfg.OIDCClientID,
ClientSecret: cfg.OIDCClientSecret.Reveal(),
PublicURL: cfg.PublicURL,
SecretKey: []byte(cfg.SecretKey.Reveal()),
AllowedGroups: cfg.OIDCAllowedGroups,
}, sessions, logger)
if err != nil {
return err
}
router, err := server.NewRouter(logger, version, authFlow, frontend.Assets())
router, err := server.NewRouter(logger, version, authFlow, sessions, frontend.Assets())
if err != nil {
return err
}
@@ -115,14 +120,6 @@ func openStore(ctx context.Context, dataDir string) (*store.Store, error) {
return db, nil
}
// sessionsPending refuses every login until the session layer (Feature 03
// task 2) replaces it.
type sessionsPending struct{}
func (sessionsPending) StartSession(http.ResponseWriter, *http.Request, auth.Claims) error {
return errors.New("session layer not implemented")
}
// logListening logs the startup line. Only the public URL's origin is logged
// so no credential-bearing URL component can reach the log.
func logListening(logger *slog.Logger, cfg config.Config) {
+35
View File
@@ -0,0 +1,35 @@
package auth
import (
"context"
"slices"
)
// Actor is the signed-in household member a request acts for. Handlers
// record Actor.Subject as updated_by on edits.
type Actor struct {
Subject string
Email string
Name string
Groups []string
}
// Actor returns the household member s belongs to.
func (s Session) Actor() Actor {
return Actor{Subject: s.Subject, Email: s.Email, Name: s.Name, Groups: slices.Clone(s.Groups)}
}
type actorKey struct{}
// WithActor returns a copy of ctx carrying actor. The session middleware
// calls it for every authenticated request.
func WithActor(ctx context.Context, actor Actor) context.Context {
return context.WithValue(ctx, actorKey{}, actor)
}
// ActorFrom returns the actor WithActor stored in ctx. ok is false when ctx
// has none, which behind the session middleware means a wiring bug.
func ActorFrom(ctx context.Context) (actor Actor, ok bool) {
actor, ok = ctx.Value(actorKey{}).(Actor)
return actor, ok
}
+32
View File
@@ -0,0 +1,32 @@
package auth
import (
"context"
"slices"
"testing"
)
func TestActorContext(t *testing.T) {
t.Parallel()
if _, ok := ActorFrom(context.Background()); ok {
t.Error("ActorFrom found an actor in an empty context")
}
want := Actor{Subject: "user-123", Email: "cook@example.com", Name: "Home Cook", Groups: []string{"mealquest-admin"}}
got, ok := ActorFrom(WithActor(context.Background(), want))
if !ok || got.Subject != want.Subject || got.Email != want.Email || got.Name != want.Name || !slices.Equal(got.Groups, want.Groups) {
t.Errorf("ActorFrom = %+v (ok=%v), want %+v", got, ok, want)
}
}
func TestSessionActor(t *testing.T) {
t.Parallel()
s := Session{Subject: "user-123", Email: "cook@example.com", Name: "Home Cook", Groups: []string{"mealquest-admin"}, CSRFToken: "t"}
a := s.Actor()
if a.Subject != s.Subject || a.Email != s.Email || a.Name != s.Name || !slices.Equal(a.Groups, s.Groups) {
t.Errorf("Actor = %+v, want the session's identity", a)
}
a.Groups[0] = "changed"
if s.Groups[0] != "mealquest-admin" {
t.Error("Actor shares the session's groups slice")
}
}
+115 -16
View File
@@ -12,6 +12,7 @@ import (
"log/slog"
"net/http"
"net/url"
"slices"
"time"
"github.com/coreos/go-oidc/v3/oidc"
@@ -25,6 +26,7 @@ import (
const (
LoginPath = "/auth/login"
CallbackPath = "/auth/callback"
LogoutPath = "/auth/logout"
)
// providerTimeout bounds every request to the identity provider.
@@ -43,11 +45,14 @@ type Claims struct {
Groups []string `json:"groups"`
}
// SessionStarter is the session layer's entry point. StartSession is called
// once per successful login with claims from a verified ID token; it must set
// the session cookie on w. A returned error aborts the login with 500.
type SessionStarter interface {
// Sessions is the session layer. StartSession is called once per successful
// login with claims from a verified ID token; it must set the session cookie
// on w. EndSession is called on logout; it must end the session r carries,
// if any, and clear the cookie on w. A returned error fails the request with
// 500.
type Sessions interface {
StartSession(w http.ResponseWriter, r *http.Request, claims Claims) error
EndSession(w http.ResponseWriter, r *http.Request) error
}
// Config is what New needs to reach the identity provider.
@@ -61,21 +66,29 @@ type Config struct {
PublicURL *url.URL
// SecretKey is the server key the login cookie's signing key derives from.
SecretKey []byte
// AllowedGroups are the household's groups; a sign-in is refused unless
// the account is in at least one of them.
AllowedGroups []string
}
// Auth serves the login and callback routes.
type Auth struct {
oauth oauth2.Config
verifier *oidc.IDTokenVerifier
provider *oidc.Provider
client *http.Client
cookie loginCookie
sessions SessionStarter
logger *slog.Logger
sessions Sessions
allowed []string
// endSessionURL is the provider's end_session_endpoint; logout ends
// there so the provider's own session ends too.
endSessionURL string
logger *slog.Logger
}
// New discovers the provider at cfg.Issuer and returns the login flow. It
// fails if discovery fails or the issuer the provider reports differs.
func New(ctx context.Context, cfg Config, sessions SessionStarter, logger *slog.Logger) (*Auth, error) {
func New(ctx context.Context, cfg Config, sessions Sessions, logger *slog.Logger) (*Auth, error) {
switch {
case cfg.Issuer == "", cfg.ClientID == "", cfg.ClientSecret == "":
return nil, errors.New("auth: issuer, client ID and client secret are required")
@@ -84,7 +97,9 @@ func New(ctx context.Context, cfg Config, sessions SessionStarter, logger *slog.
case len(cfg.SecretKey) == 0:
return nil, errors.New("auth: secret key is required")
case sessions == nil:
return nil, errors.New("auth: session starter is required")
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 {
@@ -98,7 +113,8 @@ func New(ctx context.Context, cfg Config, sessions SessionStarter, logger *slog.
}
endpoint := provider.Endpoint()
var discovered struct {
JWKSURI string `json:"jwks_uri"`
JWKSURI string `json:"jwks_uri"`
EndSessionURI string `json:"end_session_endpoint"`
}
if err := provider.Claims(&discovered); err != nil {
return nil, fmt.Errorf("auth: OIDC discovery: %w", err)
@@ -109,6 +125,10 @@ func New(ctx context.Context, cfg Config, sessions SessionStarter, logger *slog.
{"authorization endpoint", endpoint.AuthURL},
{"token endpoint", endpoint.TokenURL},
{"JWKS endpoint", discovered.JWKSURI},
{"end session endpoint", discovered.EndSessionURI},
// Groups come from userinfo when the ID token carries none, and the
// access token is sent there.
{"userinfo endpoint", provider.UserInfoEndpoint()},
} {
if err := checkEndpoint(e.name, e.raw); err != nil {
return nil, err
@@ -129,10 +149,13 @@ func New(ctx context.Context, cfg Config, sessions SessionStarter, logger *slog.
ClientID: cfg.ClientID,
SupportedSigningAlgs: []string{oidc.RS256},
}),
client: client,
cookie: newLoginCookie(cfg.SecretKey),
sessions: sessions,
logger: logger,
provider: provider,
client: client,
allowed: slices.Clone(cfg.AllowedGroups),
cookie: newLoginCookie(cfg.SecretKey),
sessions: sessions,
endSessionURL: discovered.EndSessionURI,
logger: logger,
}, nil
}
@@ -169,12 +192,16 @@ func checkRedirect(req *http.Request, via []*http.Request) error {
return nil
}
// Mount registers GET LoginPath and GET CallbackPath on r.
// Mount registers GET LoginPath, GET CallbackPath and POST LogoutPath on r.
func (a *Auth) Mount(r chi.Router) {
r.Get(LoginPath, a.login)
r.Get(CallbackPath, a.callback)
r.Post(LogoutPath, a.logout)
}
// EndSessionURL is the provider's end_session_endpoint.
func (a *Auth) EndSessionURL() string { return a.endSessionURL }
// login starts the authorization code flow with PKCE (S256). The optional
// "return" query parameter must be a same-origin path; anything else fails
// the login with 400.
@@ -204,7 +231,9 @@ func (a *Auth) login(w http.ResponseWriter, r *http.Request) {
}
// callback completes the flow: it checks state against the login cookie,
// exchanges the code, verifies the ID token and nonce, then starts a session.
// exchanges the code, verifies the ID token and nonce, then starts a session
// if the account is in an allowed group. Membership is checked on every
// login, so an account removed from the group is refused at its next one.
func (a *Auth) callback(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
ctx := r.Context()
@@ -242,6 +271,22 @@ func (a *Auth) callback(w http.ResponseWriter, r *http.Request) {
http.Error(w, "sign-in failed", http.StatusUnauthorized)
return
}
if !a.member(claims.Groups) {
if claims.Groups == nil {
a.logger.WarnContext(ctx, "sign-in refused: no groups claim in the ID token or userinfo", "subject", claims.Subject)
} else {
a.logger.WarnContext(ctx, "sign-in refused: not in an allowed group", "subject", claims.Subject)
}
// A session this browser already holds ends too, so a refused
// sign-in never leaves it signed in.
if err := a.sessions.EndSession(w, r); err != nil {
a.logger.ErrorContext(ctx, "end session", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
a.refuse(w, r)
return
}
if err := a.sessions.StartSession(w, r, claims); err != nil {
a.logger.ErrorContext(ctx, "start session", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
@@ -250,9 +295,22 @@ func (a *Auth) callback(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, state.Return, http.StatusSeeOther)
}
// logout ends the session, clears the cookie and sends the browser to the
// provider's end_session_endpoint to end the provider session as well.
func (a *Auth) logout(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
if err := a.sessions.EndSession(w, r); err != nil {
a.logger.ErrorContext(r.Context(), "end session", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, a.endSessionURL, http.StatusSeeOther)
}
// verify exchanges code for tokens and returns the verified ID token claims.
// The verifier enforces RS256, issuer, audience and expiry; nonce is checked
// here.
// here. When the ID token has no groups claim, Groups comes from userinfo;
// it stays nil when neither has one.
func (a *Auth) verify(ctx context.Context, code string, state loginState) (Claims, error) {
token, err := a.oauth.Exchange(context.WithValue(ctx, oauth2.HTTPClient, a.client), code,
oauth2.VerifierOption(state.Verifier))
@@ -277,9 +335,50 @@ func (a *Auth) verify(ctx context.Context, code string, state loginState) (Claim
if claims.Subject == "" {
return Claims{}, errors.New("ID token has no subject")
}
if claims.Groups == nil {
groups, err := a.userinfoGroups(ctx, token, claims.Subject)
if err != nil {
return Claims{}, err
}
claims.Groups = groups
}
return claims, nil
}
// userinfoGroups returns the groups claim from the userinfo endpoint, nil if
// it has none. The userinfo subject must match the ID token's subject.
func (a *Auth) userinfoGroups(ctx context.Context, token *oauth2.Token, subject string) ([]string, error) {
info, err := a.provider.UserInfo(oidc.ClientContext(ctx, a.client), oauth2.StaticTokenSource(token))
if err != nil {
return nil, userinfoError(err)
}
if info.Subject != subject {
return nil, errors.New("userinfo subject does not match the ID token")
}
var claims struct {
Groups []string `json:"groups"`
}
if err := info.Claims(&claims); err != nil {
return nil, fmt.Errorf("decode userinfo claims: %w", err)
}
return claims.Groups, nil
}
// userinfoError reduces a failed userinfo request to a fixed category, like
// exchangeError: go-oidc's error text can carry the response body.
func userinfoError(err error) error {
var transport *url.Error
if errors.As(err, &transport) {
return fmt.Errorf("userinfo: request failed: %s: %w", transport.Op, transport.Err)
}
return errors.New("userinfo: endpoint returned an unusable response")
}
// member reports whether groups contains an allowed group.
func (a *Auth) member(groups []string) bool {
return slices.ContainsFunc(groups, func(g string) bool { return slices.Contains(a.allowed, g) })
}
// exchangeError reduces a failed code exchange to a fixed category and safe
// metadata. The oauth2 error text can carry the token endpoint's response
// body or error_description, which may echo credentials, so it is dropped.
+98 -32
View File
@@ -36,6 +36,9 @@ const (
var testPublicURL = &url.URL{Scheme: "https", Host: "meals.example.com"}
// testAllowedGroups are the household's groups in tests.
var testAllowedGroups = []string{"mealquest-admin", "mealquest-family"}
// fakeProvider is an OIDC provider serving discovery, JWKS and a token
// endpoint. Each issued code maps to the ID token the token endpoint returns.
type fakeProvider struct {
@@ -50,6 +53,12 @@ type fakeProvider struct {
// tokenReply, when set, is returned by the token endpoint instead of a
// token.
tokenReply *tokenReply
// userinfo is the userinfo endpoint's response; userinfoHits counts
// requests to it.
userinfo map[string]any
userinfoHits int
// userinfoReply, when set, is returned by the userinfo endpoint instead.
userinfoReply *tokenReply
}
type tokenReply struct {
@@ -73,6 +82,7 @@ func newFakeProvider(t *testing.T) *fakeProvider {
mux.HandleFunc("GET /.well-known/openid-configuration", p.discovery)
mux.HandleFunc("GET /jwks", p.jwks)
mux.HandleFunc("POST /token", p.token)
mux.HandleFunc("GET /userinfo", p.userinfoEndpoint)
p.srv = httptest.NewServer(mux)
t.Cleanup(p.srv.Close)
return p
@@ -86,6 +96,8 @@ func (p *fakeProvider) discovery(w http.ResponseWriter, _ *http.Request) {
"authorization_endpoint": p.issuer() + "/authorize",
"token_endpoint": p.issuer() + "/token",
"jwks_uri": p.issuer() + "/jwks",
"userinfo_endpoint": p.issuer() + "/userinfo",
"end_session_endpoint": p.issuer() + "/end-session/",
"response_types_supported": []string{"code"},
"subject_types_supported": []string{"public"},
"id_token_signing_alg_values_supported": []string{"RS256", "HS256"},
@@ -147,6 +159,41 @@ func (p *fakeProvider) token(w http.ResponseWriter, r *http.Request) {
})
}
// userinfoEndpoint checks the access token and returns p.userinfo, which
// defaults to the test user without a groups claim.
func (p *fakeProvider) userinfoEndpoint(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer access-token" {
http.Error(w, `{"error":"invalid_token"}`, http.StatusUnauthorized)
return
}
p.mu.Lock()
p.userinfoHits++
info, reply := p.userinfo, p.userinfoReply
p.mu.Unlock()
if reply != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(reply.status)
io.WriteString(w, reply.body)
return
}
if info == nil {
info = map[string]any{"sub": "user-123", "email": "cook@example.com"}
}
writeJSON(w, info)
}
func (p *fakeProvider) setUserinfo(info map[string]any) {
p.mu.Lock()
defer p.mu.Unlock()
p.userinfo = info
}
func (p *fakeProvider) userinfoRequests() int {
p.mu.Lock()
defer p.mu.Unlock()
return p.userinfoHits
}
// authorize stands in for the user signing in at the provider: it records a
// code bound to the PKCE challenge and returns it.
func (p *fakeProvider) authorize(challenge, idToken string) string {
@@ -223,11 +270,12 @@ func writeJSON(w http.ResponseWriter, v any) {
json.NewEncoder(w).Encode(v)
}
// memorySessions is an in-memory SessionStarter that records the claims it
// was handed.
// memorySessions is an in-memory Sessions that records the claims it was
// handed and how many sessions were ended.
type memorySessions struct {
mu sync.Mutex
claims []Claims
ended int
err error
}
@@ -242,6 +290,16 @@ func (m *memorySessions) StartSession(w http.ResponseWriter, _ *http.Request, cl
return nil
}
func (m *memorySessions) EndSession(http.ResponseWriter, *http.Request) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.err != nil {
return m.err
}
m.ended++
return nil
}
func (m *memorySessions) started() []Claims {
m.mu.Lock()
defer m.mu.Unlock()
@@ -279,11 +337,12 @@ func newHarness(t *testing.T) *harness {
sessions := &memorySessions{}
logs := &syncBuffer{}
a, err := New(context.Background(), Config{
Issuer: p.issuer(),
ClientID: testClientID,
ClientSecret: testClientSecret,
PublicURL: testPublicURL,
SecretKey: []byte("server-secret-key"),
Issuer: p.issuer(),
ClientID: testClientID,
ClientSecret: testClientSecret,
PublicURL: testPublicURL,
SecretKey: []byte("server-secret-key"),
AllowedGroups: testAllowedGroups,
}, sessions, slog.New(slog.NewTextHandler(logs, nil)))
if err != nil {
t.Fatal(err)
@@ -718,11 +777,12 @@ func TestNewFailsWhenDiscoveryFails(t *testing.T) {
srv := httptest.NewServer(http.NotFoundHandler())
t.Cleanup(srv.Close)
_, err := New(context.Background(), Config{
Issuer: srv.URL,
ClientID: testClientID,
ClientSecret: testClientSecret,
PublicURL: testPublicURL,
SecretKey: []byte("k"),
Issuer: srv.URL,
ClientID: testClientID,
ClientSecret: testClientSecret,
PublicURL: testPublicURL,
SecretKey: []byte("k"),
AllowedGroups: testAllowedGroups,
}, &memorySessions{}, slog.New(slog.DiscardHandler))
if err == nil {
t.Fatal("New succeeded without a discovery document")
@@ -735,11 +795,12 @@ func TestNewFailsOnIssuerMismatch(t *testing.T) {
// Discovery trims the trailing slash, so this fetches the provider's
// document, which reports the issuer without it.
_, err := New(context.Background(), Config{
Issuer: p.issuer() + "/",
ClientID: testClientID,
ClientSecret: testClientSecret,
PublicURL: testPublicURL,
SecretKey: []byte("k"),
Issuer: p.issuer() + "/",
ClientID: testClientID,
ClientSecret: testClientSecret,
PublicURL: testPublicURL,
SecretKey: []byte("k"),
AllowedGroups: testAllowedGroups,
}, &memorySessions{}, slog.New(slog.DiscardHandler))
if err == nil {
t.Fatal("New accepted a provider reporting a different issuer")
@@ -760,11 +821,12 @@ func TestNewRejectsRemoteHTTPIssuer(t *testing.T) {
t.Parallel()
for _, issuer := range []string{"http://auth.example.com/application/o/mealquest/", "http://192.0.2.10"} {
_, err := New(context.Background(), Config{
Issuer: issuer,
ClientID: testClientID,
ClientSecret: testClientSecret,
PublicURL: testPublicURL,
SecretKey: []byte("k"),
Issuer: issuer,
ClientID: testClientID,
ClientSecret: testClientSecret,
PublicURL: testPublicURL,
SecretKey: []byte("k"),
AllowedGroups: testAllowedGroups,
}, &memorySessions{}, slog.New(slog.DiscardHandler))
if err == nil || !strings.Contains(err.Error(), "issuer") {
t.Errorf("New(issuer %q) error = %v, want issuer rejected", issuer, err)
@@ -780,6 +842,8 @@ func TestNewRejectsRemoteHTTPEndpoint(t *testing.T) {
{"authorization_endpoint", "authorization endpoint"},
{"token_endpoint", "token endpoint"},
{"jwks_uri", "JWKS endpoint"},
{"end_session_endpoint", "end session endpoint"},
{"userinfo_endpoint", "userinfo endpoint"},
}
for _, tc := range tests {
t.Run(tc.field, func(t *testing.T) {
@@ -787,11 +851,12 @@ func TestNewRejectsRemoteHTTPEndpoint(t *testing.T) {
p := newFakeProvider(t)
p.discoveryOverrides = map[string]any{tc.field: "http://auth.example.com/" + tc.field}
_, err := New(context.Background(), Config{
Issuer: p.issuer(),
ClientID: testClientID,
ClientSecret: testClientSecret,
PublicURL: testPublicURL,
SecretKey: []byte("k"),
Issuer: p.issuer(),
ClientID: testClientID,
ClientSecret: testClientSecret,
PublicURL: testPublicURL,
SecretKey: []byte("k"),
AllowedGroups: testAllowedGroups,
}, &memorySessions{}, slog.New(slog.DiscardHandler))
if err == nil || !strings.Contains(err.Error(), tc.name) {
t.Errorf("New error = %v, want %s rejected", err, tc.name)
@@ -827,11 +892,12 @@ func TestCallbackRefusesTokenRedirectToHTTP(t *testing.T) {
sessions := &memorySessions{}
logs := &syncBuffer{}
a, err := New(context.Background(), Config{
Issuer: p.issuer(),
ClientID: testClientID,
ClientSecret: testClientSecret,
PublicURL: testPublicURL,
SecretKey: []byte("server-secret-key"),
Issuer: p.issuer(),
ClientID: testClientID,
ClientSecret: testClientSecret,
PublicURL: testPublicURL,
SecretKey: []byte("server-secret-key"),
AllowedGroups: testAllowedGroups,
}, sessions, slog.New(slog.NewTextHandler(logs, nil)))
if err != nil {
t.Fatal(err)
+264
View File
@@ -0,0 +1,264 @@
package auth
import (
"context"
"errors"
"io"
"log/slog"
"net/http"
"net/url"
"slices"
"strings"
"testing"
)
// signInWith completes a login whose ID token has claims edited by edit.
func (h *harness) signInWith(t *testing.T, edit func(claims map[string]any)) *http.Response {
t.Helper()
l := h.login(t, LoginPath)
c := h.provider.claims(l.query.Get("nonce"))
edit(c)
return h.callback(l, l.query.Get("state"), h.provider.signRS256(c))
}
// assertRefused checks resp is the refused-sign-in page and starts no
// session.
func assertRefused(t *testing.T, resp *http.Response) {
t.Helper()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("callback status = %d, want 403", resp.StatusCode)
}
if ct := resp.Header.Get("Content-Type"); ct != "text/html; charset=utf-8" {
t.Errorf("Content-Type = %q, want text/html; charset=utf-8", ct)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{
"This kitchen is for the household only",
`<form method="post" action="/auth/logout">`,
"Sign out",
} {
if !strings.Contains(string(body), want) {
t.Errorf("refused page lacks %q:\n%s", want, body)
}
}
for _, c := range resp.Cookies() {
// Clearing a session cookie (negative Max-Age) is expected.
if (c.Name == SessionCookieName || c.Name == "test_session") && c.MaxAge >= 0 {
t.Errorf("refused sign-in set session cookie %s", c.Name)
}
}
assertLoginCookieCleared(t, resp)
}
func TestGroupGateMember(t *testing.T) {
t.Parallel()
// Any one allowed group is enough, alongside groups that are not allowed.
h := newHarness(t)
resp := h.signInWith(t, func(c map[string]any) { c["groups"] = []string{"guests", "mealquest-family"} })
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("callback status = %d, want 303", resp.StatusCode)
}
if started := h.sessions.started(); len(started) != 1 || !slices.Equal(started[0].Groups, []string{"guests", "mealquest-family"}) {
t.Errorf("sessions started = %+v, want one with the ID token groups", started)
}
if n := h.provider.userinfoRequests(); n != 0 {
t.Errorf("userinfo requested %d times, want 0 when the ID token has groups", n)
}
}
func TestGroupGateRefused(t *testing.T) {
t.Parallel()
tests := []struct {
name string
groups any // ID token groups claim; nil removes it
userinfo map[string]any
}{
{name: "non-member", groups: []string{"guests", "MEALQUEST-ADMIN"}},
{name: "empty groups claim", groups: []string{}},
{name: "groups claim missing from ID token and userinfo", groups: nil},
{
name: "non-member in userinfo",
groups: nil,
userinfo: map[string]any{"sub": "user-123", "groups": []string{"guests"}},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
h := newHarness(t)
if tc.userinfo != nil {
h.provider.setUserinfo(tc.userinfo)
}
resp := h.signInWith(t, func(c map[string]any) {
if tc.groups == nil {
delete(c, "groups")
} else {
c["groups"] = tc.groups
}
})
assertRefused(t, resp)
if started := h.sessions.started(); len(started) != 0 {
t.Errorf("sessions started = %+v, want none", started)
}
if !strings.Contains(h.logs.String(), "sign-in refused") {
t.Errorf("refusal not logged:\n%s", h.logs.String())
}
})
}
}
func TestGroupGateUsesUserinfoWhenIDTokenHasNoGroups(t *testing.T) {
t.Parallel()
h := newHarness(t)
h.provider.setUserinfo(map[string]any{"sub": "user-123", "groups": []string{"mealquest-admin"}})
resp := h.signInWith(t, func(c map[string]any) { delete(c, "groups") })
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("callback status = %d, want 303", resp.StatusCode)
}
if started := h.sessions.started(); len(started) != 1 || !slices.Equal(started[0].Groups, []string{"mealquest-admin"}) {
t.Errorf("sessions started = %+v, want one with the userinfo groups", started)
}
if n := h.provider.userinfoRequests(); n != 1 {
t.Errorf("userinfo requested %d times, want 1", n)
}
}
func TestGroupGateUserinfoFailures(t *testing.T) {
t.Parallel()
tests := []struct {
name string
userinfo map[string]any
}{
{"subject mismatch", map[string]any{"sub": "someone-else", "groups": []string{"mealquest-admin"}}},
{"malformed groups", map[string]any{"sub": "user-123", "groups": "mealquest-admin"}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
h := newHarness(t)
h.provider.setUserinfo(tc.userinfo)
resp := h.signInWith(t, func(c map[string]any) { delete(c, "groups") })
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("callback status = %d, want 401", resp.StatusCode)
}
if started := h.sessions.started(); len(started) != 0 {
t.Errorf("sessions started = %+v, want none", started)
}
})
}
}
// A failed userinfo request fails the sign-in without logging the
// endpoint's response, which may echo credentials.
func TestGroupGateUserinfoErrorNotLogged(t *testing.T) {
t.Parallel()
h := newHarness(t)
h.provider.mu.Lock()
h.provider.userinfoReply = &tokenReply{status: http.StatusInternalServerError, body: `{"error":"leaked-s3cret"}`}
h.provider.mu.Unlock()
resp := h.signInWith(t, func(c map[string]any) { delete(c, "groups") })
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("callback status = %d, want 401", resp.StatusCode)
}
if started := h.sessions.started(); len(started) != 0 {
t.Errorf("sessions started = %+v, want none", started)
}
logs := h.logs.String()
if !strings.Contains(logs, "userinfo") || strings.Contains(logs, "leaked-s3cret") {
t.Errorf("logs = %s, want the userinfo failure without the response body", logs)
}
}
// Membership is checked at every login: after removal from the group, the
// next login is refused and starts no session.
func TestGroupGateRemovedMemberRefusedAtNextLogin(t *testing.T) {
t.Parallel()
h := newSessionHarness(t)
h.signIn(t)
if n := len(h.rows(t)); n != 1 {
t.Fatalf("sessions after first login = %d, want 1", n)
}
resp := h.signInWith(t, func(c map[string]any) { c["groups"] = []string{"former-household"} })
assertRefused(t, resp)
if n := len(h.rows(t)); n != 1 {
t.Errorf("sessions after refused login = %d, want still 1", n)
}
}
// A refused sign-in from a browser that still holds a session ends that
// session and clears its cookie.
func TestGroupGateRefusalEndsExistingSession(t *testing.T) {
t.Parallel()
h := newSessionHarness(t)
_, cookie := h.signIn(t)
l := h.login(t, LoginPath)
c := h.provider.claims(l.query.Get("nonce"))
c["groups"] = []string{"former-household"}
code := h.provider.authorize(l.query.Get("code_challenge"), h.provider.signRS256(c))
q := url.Values{"code": {code}, "state": {l.query.Get("state")}}
resp := h.get(CallbackPath+"?"+q.Encode(), l.cookie, cookie)
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("callback status = %d, want 403", resp.StatusCode)
}
if n := len(h.rows(t)); n != 0 {
t.Errorf("sessions after refused login = %d, want 0", n)
}
if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) {
t.Errorf("old session lookup error = %v, want ErrNoSession", err)
}
cleared := false
for _, c := range resp.Cookies() {
cleared = cleared || (c.Name == SessionCookieName && c.MaxAge < 0)
}
if !cleared {
t.Error("refused sign-in did not clear the session cookie")
}
}
func TestGroupGateRefusalEndSessionError(t *testing.T) {
t.Parallel()
h := newHarness(t)
h.sessions.err = errors.New("database down")
resp := h.signInWith(t, func(c map[string]any) { c["groups"] = []string{"guests"} })
if resp.StatusCode != http.StatusInternalServerError {
t.Errorf("callback status = %d, want 500", resp.StatusCode)
}
}
func TestNewRequiresAllowedGroups(t *testing.T) {
t.Parallel()
p := newFakeProvider(t)
_, err := New(context.Background(), Config{
Issuer: p.issuer(),
ClientID: testClientID,
ClientSecret: testClientSecret,
PublicURL: testPublicURL,
SecretKey: []byte("k"),
}, &memorySessions{}, slog.New(slog.DiscardHandler))
if err == nil || !strings.Contains(err.Error(), "allowed group") {
t.Errorf("New error = %v, want missing allowed groups named", err)
}
}
func TestNewRequiresUserinfoEndpoint(t *testing.T) {
t.Parallel()
p := newFakeProvider(t)
p.discoveryOverrides = map[string]any{"userinfo_endpoint": nil}
_, err := New(context.Background(), Config{
Issuer: p.issuer(),
ClientID: testClientID,
ClientSecret: testClientSecret,
PublicURL: testPublicURL,
SecretKey: []byte("k"),
AllowedGroups: testAllowedGroups,
}, &memorySessions{}, slog.New(slog.DiscardHandler))
if err == nil || !strings.Contains(err.Error(), "userinfo endpoint") {
t.Errorf("New error = %v, want the missing userinfo endpoint named", err)
}
}
+37
View File
@@ -0,0 +1,37 @@
package auth
import (
"io"
"net/http"
)
// refusedPage is shown when a sign-in succeeds at the provider but the
// account is not in the household's group. Signing out ends the provider
// session too, so another account can sign in.
const refusedPage = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MealQuest</title>
</head>
<body>
<main>
<h1>This kitchen is for the household only</h1>
<p>The account you signed in with is not part of our kitchen.</p>
<form method="post" action="` + LogoutPath + `">
<button type="submit">Sign out</button>
</form>
</main>
</body>
</html>
`
// refuse writes the refused-sign-in page with 403.
func (a *Auth) refuse(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusForbidden)
if _, err := io.WriteString(w, refusedPage); err != nil {
a.logger.WarnContext(r.Context(), "write refused sign-in page", "error", err)
}
}
+238
View File
@@ -0,0 +1,238 @@
package auth
import (
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// SessionCookieName is the session cookie. The __Host- prefix makes the
// browser insist on Secure, Path=/ and no Domain.
const SessionCookieName = "__Host-mealquest_session"
// sessionIDBytes is the size of the random session ID: 256 bits.
const sessionIDBytes = 32
// ErrNoSession means the request carries no live session: the cookie is
// missing or malformed, or its session is unknown, ended or expired.
var ErrNoSession = errors.New("auth: no valid session")
// Session is a signed-in household member's server-side session.
type Session struct {
Subject string
Email string
Name string
Groups []string
CSRFToken string
CreatedAt time.Time
// LastSeenAt is the last time the session was used.
LastSeenAt time.Time
// ExpiresAt is when the session ends unless used again first.
ExpiresAt time.Time
}
// SessionManager keeps sessions in the sessions table. The cookie holds only
// the random session ID; the table holds only its SHA-256 hash. A session
// ends after idle without use and always max after it was created.
type SessionManager struct {
db *store.Store
idle time.Duration
max time.Duration
// now is the clock; tests replace it.
now func() time.Time
}
// NewSessionManager returns a SessionManager over db. idle and max must be
// positive with max >= idle.
func NewSessionManager(db *store.Store, idle, max time.Duration) (*SessionManager, error) {
switch {
case db == nil:
return nil, errors.New("auth: session store is required")
case idle <= 0 || max <= 0:
return nil, errors.New("auth: session idle and max lifetimes must be positive")
case max < idle:
return nil, errors.New("auth: session max lifetime must be at least the idle lifetime")
}
return &SessionManager{db: db, idle: idle, max: max, now: time.Now}, nil
}
// StartSession creates a session for claims and sets the session cookie.
func (m *SessionManager) StartSession(w http.ResponseWriter, r *http.Request, claims Claims) error {
if claims.Subject == "" {
return errors.New("auth: session needs a subject")
}
id, csrf := randomToken(), randomToken()
groups := claims.Groups
if groups == nil {
groups = []string{}
}
groupsJSON, err := json.Marshal(groups)
if err != nil {
return fmt.Errorf("auth: encode session groups: %w", err)
}
now := m.timestamp()
if _, err := m.db.Writer.ExecContext(r.Context(), `
INSERT INTO sessions (id_hash, subject, email, name, groups, csrf_token, created_at, last_seen_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
hashSessionID(id), claims.Subject, claims.Email, claims.Name, string(groupsJSON), csrf,
now, now, m.expiry(now.Time, now.Time),
); err != nil {
return fmt.Errorf("auth: create session: %w", err)
}
// The cookie outlives idle gaps so a returning device presents its ID;
// the server decides whether the session is still live. Max-Age is whole
// seconds, rounded up so the cookie never ends before the absolute limit.
http.SetCookie(w, sessionCookie(id, cookieMaxAge(m.max)))
return nil
}
// cookieMaxAge is lifetime in whole seconds, rounded up. It rounds the
// quotient rather than adding to lifetime, which would overflow near the
// largest time.Duration.
func cookieMaxAge(lifetime time.Duration) int {
seconds := lifetime / time.Second
if lifetime%time.Second != 0 {
seconds++
}
return int(seconds)
}
// SessionFor returns the live session r carries and slides its last use to
// now. It returns ErrNoSession when there is none; an expired session is
// deleted.
func (m *SessionManager) SessionFor(r *http.Request) (Session, error) {
idHash, err := sessionIDHash(r)
if err != nil {
return Session{}, err
}
now := m.timestamp()
var s Session
live := false
err = m.db.WithTx(r.Context(), func(tx *sql.Tx) error {
var groupsJSON string
var created, lastSeen, expires store.Timestamp
err := tx.QueryRowContext(r.Context(), `
SELECT subject, email, name, groups, csrf_token, created_at, last_seen_at, expires_at
FROM sessions WHERE id_hash = ?`, idHash,
).Scan(&s.Subject, &s.Email, &s.Name, &groupsJSON, &s.CSRFToken, &created, &lastSeen, &expires)
if errors.Is(err, sql.ErrNoRows) {
return nil
}
if err != nil {
return fmt.Errorf("auth: read session: %w", err)
}
if !now.Before(expires.Time) || now.Sub(lastSeen.Time) >= m.idle || now.Sub(created.Time) >= m.max {
if _, err := tx.ExecContext(r.Context(), `DELETE FROM sessions WHERE id_hash = ?`, idHash); err != nil {
return fmt.Errorf("auth: delete expired session: %w", err)
}
return nil
}
if err := json.Unmarshal([]byte(groupsJSON), &s.Groups); err != nil {
return fmt.Errorf("auth: decode session groups: %w", err)
}
expiresAt := m.expiry(created.Time, now.Time)
if _, err := tx.ExecContext(r.Context(), `
UPDATE sessions SET last_seen_at = ?, expires_at = ? WHERE id_hash = ?`,
now, expiresAt, idHash,
); err != nil {
return fmt.Errorf("auth: slide session: %w", err)
}
s.CreatedAt, s.LastSeenAt, s.ExpiresAt = created.Time, now.Time, expiresAt.Time
live = true
return nil
})
if err != nil {
return Session{}, err
}
if !live {
return Session{}, ErrNoSession
}
return s, nil
}
// EndSession deletes the session r carries, if any, and clears the cookie.
func (m *SessionManager) EndSession(w http.ResponseWriter, r *http.Request) error {
http.SetCookie(w, sessionCookie("", -1))
idHash, err := sessionIDHash(r)
if errors.Is(err, ErrNoSession) {
return nil
}
if err != nil {
return err
}
if _, err := m.db.Writer.ExecContext(r.Context(), `DELETE FROM sessions WHERE id_hash = ?`, idHash); err != nil {
return fmt.Errorf("auth: delete session: %w", err)
}
return nil
}
// timestamp is the clock's current instant at stored precision.
func (m *SessionManager) timestamp() store.Timestamp {
return store.Timestamp{Time: m.now().UTC().Truncate(time.Millisecond)}
}
// expiry is when a session created at created and last used at lastSeen
// ends: idle after lastSeen, but never later than max after created.
func (m *SessionManager) expiry(created, lastSeen time.Time) store.Timestamp {
idle, absolute := lastSeen.Add(m.idle), created.Add(m.max)
if absolute.Before(idle) {
return store.Timestamp{Time: absolute}
}
return store.Timestamp{Time: idle}
}
// sessionCookie is the session cookie carrying value, with maxAge seconds;
// a negative maxAge clears it.
func sessionCookie(value string, maxAge int) *http.Cookie {
return &http.Cookie{
Name: SessionCookieName,
Value: value,
Path: "/",
MaxAge: maxAge,
Secure: true,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
}
}
// sessionIDHash returns the stored hash of r's session ID, or ErrNoSession
// when the cookie is missing or is not a well-formed ID.
func sessionIDHash(r *http.Request) (string, error) {
c, err := r.Cookie(SessionCookieName)
if err != nil {
return "", ErrNoSession
}
raw, err := base64.RawURLEncoding.DecodeString(c.Value)
if err != nil || len(raw) != sessionIDBytes {
return "", ErrNoSession
}
return hashSessionID(c.Value), nil
}
// hashSessionID is the SHA-256 hex digest of the cookie value.
func hashSessionID(id string) string {
sum := sha256.Sum256([]byte(id))
return hex.EncodeToString(sum[:])
}
// randomToken returns 256 random bits, base64url-encoded without padding.
// crypto/rand.Read never returns an error; it crashes the program instead.
func randomToken() string {
b := make([]byte, sessionIDBytes)
rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
}
+523
View File
@@ -0,0 +1,523 @@
package auth
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"slices"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
)
const (
testIdle = 30 * 24 * time.Hour
testMax = 180 * 24 * time.Hour
)
// testEpoch is the fake clock's starting instant.
var testEpoch = time.Date(2026, 10, 3, 18, 30, 0, 0, time.UTC)
// sessionHarness runs the real login flow against a fake provider with a
// SessionManager over a migrated database and a fake clock.
type sessionHarness struct {
*harness
db *store.Store
sessions *SessionManager
auth *Auth
clock time.Time
}
func newSessionHarness(t *testing.T) *sessionHarness {
t.Helper()
ctx := context.Background()
db, err := store.Open(ctx, t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := db.Close(); err != nil {
t.Error(err)
}
})
if err := store.Migrate(ctx, db.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
sessions, err := NewSessionManager(db, testIdle, testMax)
if err != nil {
t.Fatal(err)
}
h := &sessionHarness{db: db, sessions: sessions, clock: testEpoch}
sessions.now = func() time.Time { return h.clock }
p := newFakeProvider(t)
logs := &syncBuffer{}
a, err := New(ctx, Config{
Issuer: p.issuer(),
ClientID: testClientID,
ClientSecret: testClientSecret,
PublicURL: testPublicURL,
SecretKey: []byte("server-secret-key"),
AllowedGroups: testAllowedGroups,
}, sessions, slog.New(slog.NewTextHandler(logs, nil)))
if err != nil {
t.Fatal(err)
}
r := chi.NewRouter()
a.Mount(r)
h.harness = &harness{provider: p, router: r, logs: logs}
h.auth = a
return h
}
func (h *sessionHarness) advance(d time.Duration) { h.clock = h.clock.Add(d) }
// signIn completes a login and returns the callback response and the
// session cookie it set.
func (h *sessionHarness) signIn(t *testing.T) (*http.Response, *http.Cookie) {
t.Helper()
l := h.login(t, LoginPath)
resp := h.callback(l, l.query.Get("state"), h.provider.signRS256(h.provider.claims(l.query.Get("nonce"))))
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("callback status = %d, want 303", resp.StatusCode)
}
for _, c := range resp.Cookies() {
if c.Name == SessionCookieName {
return resp, c
}
}
t.Fatalf("callback set no %s cookie", SessionCookieName)
return nil, nil
}
// use looks up the session cookie c names.
func (h *sessionHarness) use(c *http.Cookie) (Session, error) {
req := httptest.NewRequest(http.MethodGet, "/api/anything", nil)
if c != nil {
req.AddCookie(c)
}
return h.sessions.SessionFor(req)
}
type sessionRow struct {
idHash, subject, email, name, groups, csrf string
created, lastSeen, expires store.Timestamp
}
func (h *sessionHarness) rows(t *testing.T) []sessionRow {
t.Helper()
rs, err := h.db.Reader.Query(`SELECT id_hash, subject, email, name, groups, csrf_token,
created_at, last_seen_at, expires_at FROM sessions`)
if err != nil {
t.Fatal(err)
}
defer rs.Close()
var out []sessionRow
for rs.Next() {
var r sessionRow
if err := rs.Scan(&r.idHash, &r.subject, &r.email, &r.name, &r.groups, &r.csrf,
&r.created, &r.lastSeen, &r.expires); err != nil {
t.Fatal(err)
}
out = append(out, r)
}
if err := rs.Err(); err != nil {
t.Fatal(err)
}
return out
}
// sessionSetCookie returns the raw Set-Cookie header for the session cookie.
func sessionSetCookie(t *testing.T, resp *http.Response) string {
t.Helper()
for _, v := range resp.Header.Values("Set-Cookie") {
if strings.HasPrefix(v, SessionCookieName+"=") {
return v
}
}
t.Fatalf("no Set-Cookie for %s in %q", SessionCookieName, resp.Header.Values("Set-Cookie"))
return ""
}
// assertSessionCookieAttributes checks the attributes the __Host- prefix
// and D-004 require, on the raw header so nothing is lost in parsing.
func assertSessionCookieAttributes(t *testing.T, raw string) {
t.Helper()
attrs := map[string]bool{}
for _, a := range strings.Split(raw, ";")[1:] {
name, _, _ := strings.Cut(strings.TrimSpace(a), "=")
attrs[strings.ToLower(name)] = true
}
for _, want := range []string{"Path=/", "HttpOnly", "Secure", "SameSite=Lax"} {
if !slices.Contains(strings.Split(raw, "; "), want) {
t.Errorf("session Set-Cookie %q lacks %s", raw, want)
}
}
if attrs["domain"] {
t.Errorf("session Set-Cookie %q has a Domain", raw)
}
}
func sha256Hex(s string) string {
sum := sha256.Sum256([]byte(s))
return hex.EncodeToString(sum[:])
}
func TestCallbackStartsSession(t *testing.T) {
t.Parallel()
h := newSessionHarness(t)
resp, cookie := h.signIn(t)
assertSessionCookieAttributes(t, sessionSetCookie(t, resp))
if cookie.MaxAge != int(testMax/time.Second) {
t.Errorf("session cookie MaxAge = %d, want %d", cookie.MaxAge, int(testMax/time.Second))
}
rows := h.rows(t)
if len(rows) != 1 {
t.Fatalf("sessions has %d rows, want 1", len(rows))
}
r := rows[0]
if len(cookie.Value) != 43 {
t.Errorf("session ID %q is %d chars, want 43 (256 bits, base64url)", cookie.Value, len(cookie.Value))
}
if r.idHash != sha256Hex(cookie.Value) {
t.Errorf("id_hash = %q, want the SHA-256 of the cookie value", r.idHash)
}
if r.subject != "user-123" || r.email != "cook@example.com" || r.name != "Home Cook" ||
r.groups != `["mealquest-admin"]` {
t.Errorf("session row = %+v, want the ID token claims", r)
}
if len(r.csrf) != 43 || r.csrf == cookie.Value {
t.Errorf("csrf_token = %q, want a separate 256-bit token", r.csrf)
}
if !r.created.Equal(testEpoch) || !r.lastSeen.Equal(testEpoch) || !r.expires.Equal(testEpoch.Add(testIdle)) {
t.Errorf("created, last_seen, expires = %v, %v, %v, want %v, %v, %v",
r.created, r.lastSeen, r.expires, testEpoch, testEpoch, testEpoch.Add(testIdle))
}
s, err := h.use(cookie)
if err != nil {
t.Fatalf("SessionFor = %v, want the new session", err)
}
if s.Subject != "user-123" || s.CSRFToken != r.csrf || !slices.Equal(s.Groups, []string{"mealquest-admin"}) {
t.Errorf("SessionFor = %+v", s)
}
}
func TestStartSessionStoresOnlyTheHash(t *testing.T) {
t.Parallel()
h := newSessionHarness(t)
_, cookie := h.signIn(t)
var n int
err := h.db.Reader.QueryRow(`SELECT count(*) FROM sessions WHERE
id_hash = ?1 OR subject = ?1 OR email = ?1 OR name = ?1 OR groups = ?1 OR csrf_token = ?1`,
cookie.Value).Scan(&n)
if err != nil {
t.Fatal(err)
}
if n != 0 {
t.Error("the raw session ID is stored in the sessions table")
}
}
func TestSessionLastSeenSlides(t *testing.T) {
t.Parallel()
h := newSessionHarness(t)
_, cookie := h.signIn(t)
// Used every 20 days, the session outlives several idle periods.
for range 4 {
h.advance(20 * 24 * time.Hour)
s, err := h.use(cookie)
if err != nil {
t.Fatalf("at %v SessionFor = %v, want the session", h.clock, err)
}
if !s.LastSeenAt.Equal(h.clock) || !s.ExpiresAt.Equal(h.clock.Add(testIdle)) {
t.Errorf("at %v session last seen %v, expires %v, want %v, %v",
h.clock, s.LastSeenAt, s.ExpiresAt, h.clock, h.clock.Add(testIdle))
}
r := h.rows(t)[0]
if !r.lastSeen.Equal(h.clock) || !r.expires.Equal(h.clock.Add(testIdle)) || !r.created.Equal(testEpoch) {
t.Errorf("at %v row created %v, last seen %v, expires %v", h.clock, r.created, r.lastSeen, r.expires)
}
}
}
func TestSessionIdleExpiry(t *testing.T) {
t.Parallel()
h := newSessionHarness(t)
_, cookie := h.signIn(t)
h.advance(testIdle - time.Millisecond)
if _, err := h.use(cookie); err != nil {
t.Fatalf("just inside the idle limit SessionFor = %v, want the session", err)
}
h.advance(testIdle)
if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) {
t.Fatalf("after idle SessionFor = %v, want ErrNoSession", err)
}
if n := len(h.rows(t)); n != 0 {
t.Errorf("expired session left %d rows, want 0", n)
}
// Turning the clock back cannot revive it.
h.clock = testEpoch
if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) {
t.Errorf("after deletion SessionFor = %v, want ErrNoSession", err)
}
}
func TestSessionAbsoluteExpiry(t *testing.T) {
t.Parallel()
h := newSessionHarness(t)
_, cookie := h.signIn(t)
for h.clock.Before(testEpoch.Add(testMax - 20*24*time.Hour)) {
h.advance(20 * 24 * time.Hour)
if _, err := h.use(cookie); err != nil {
t.Fatalf("at %v SessionFor = %v, want the session", h.clock, err)
}
}
// Last used 20 days before the absolute limit: the idle window would run
// past it, so expiry is capped there.
if r := h.rows(t)[0]; !r.expires.Equal(testEpoch.Add(testMax)) {
t.Errorf("expires_at = %v, want the absolute limit %v", r.expires, testEpoch.Add(testMax))
}
h.clock = testEpoch.Add(testMax - time.Millisecond)
if _, err := h.use(cookie); err != nil {
t.Fatalf("just inside the absolute limit SessionFor = %v, want the session", err)
}
h.clock = testEpoch.Add(testMax)
if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) {
t.Fatalf("at the absolute limit, used 1ms ago, SessionFor = %v, want ErrNoSession", err)
}
if n := len(h.rows(t)); n != 0 {
t.Errorf("expired session left %d rows, want 0", n)
}
}
// A max with fractional seconds rounds the cookie Max-Age up, so the browser
// keeps the cookie until the server-side absolute limit.
func TestSessionCookieOutlivesFractionalMax(t *testing.T) {
t.Parallel()
h := newSessionHarness(t)
h.sessions.idle, h.sessions.max = 1500*time.Millisecond, 2500*time.Millisecond
_, cookie := h.signIn(t)
if cookie.MaxAge != 3 {
t.Errorf("session cookie MaxAge = %d, want 3", cookie.MaxAge)
}
if r := h.rows(t)[0]; !r.expires.Equal(testEpoch.Add(1500 * time.Millisecond)) {
t.Errorf("expires_at = %v, want %v", r.expires, testEpoch.Add(1500*time.Millisecond))
}
h.advance(time.Second)
if _, err := h.use(cookie); err != nil {
t.Fatalf("at 1s SessionFor = %v, want the session", err)
}
h.clock = testEpoch.Add(2500*time.Millisecond - time.Millisecond)
if _, err := h.use(cookie); err != nil {
t.Fatalf("just inside the absolute limit SessionFor = %v, want the session", err)
}
h.clock = testEpoch.Add(2500 * time.Millisecond)
if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) {
t.Errorf("at the absolute limit SessionFor = %v, want ErrNoSession", err)
}
}
// A lifetime near the largest accepted duration still yields a positive,
// rounded-up cookie Max-Age rather than overflowing.
func TestSessionCookieMaxAgeNearLargestMax(t *testing.T) {
t.Parallel()
largest, err := time.ParseDuration("2562047h47m16.854s")
if err != nil {
t.Fatal(err)
}
h := newSessionHarness(t)
h.sessions.max = largest
_, cookie := h.signIn(t)
if want := 9223372037; cookie.MaxAge != want {
t.Errorf("session cookie MaxAge = %d, want %d", cookie.MaxAge, want)
}
}
// A lifetime shortened by configuration applies to existing sessions even
// though their stored expires_at is later.
func TestSessionLifetimeChecksUseCurrentConfig(t *testing.T) {
t.Parallel()
h := newSessionHarness(t)
_, cookie := h.signIn(t)
h.sessions.idle = 24 * time.Hour
h.advance(24 * time.Hour)
if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) {
t.Errorf("past the shortened idle SessionFor = %v, want ErrNoSession", err)
}
_, cookie = h.signIn(t)
h.sessions.idle, h.sessions.max = 24*time.Hour, 48*time.Hour
for range 3 {
h.advance(12 * time.Hour)
if _, err := h.use(cookie); err != nil {
t.Fatalf("SessionFor = %v, want the session", err)
}
}
h.advance(12 * time.Hour)
if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) {
t.Errorf("past the shortened max SessionFor = %v, want ErrNoSession", err)
}
// Used recently and with expires_at still ahead, but older than a max
// shortened after that use: only the absolute check ends it.
h.sessions.idle, h.sessions.max = testIdle, testMax
_, cookie = h.signIn(t)
h.advance(10 * 24 * time.Hour)
if _, err := h.use(cookie); err != nil {
t.Fatalf("SessionFor = %v, want the session", err)
}
h.sessions.max = 10 * 24 * time.Hour
h.advance(time.Hour)
if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) {
t.Errorf("past a max shortened after the last use SessionFor = %v, want ErrNoSession", err)
}
}
func TestSessionForRejectsBadCookies(t *testing.T) {
t.Parallel()
h := newSessionHarness(t)
h.signIn(t)
for name, c := range map[string]*http.Cookie{
"missing": nil,
"empty": {Name: SessionCookieName, Value: ""},
"not base64": {Name: SessionCookieName, Value: strings.Repeat("!", 43)},
"short": {Name: SessionCookieName, Value: "AAAA"},
"unknown ID": {Name: SessionCookieName, Value: randomToken()},
"other cookie": {Name: "mealquest_session", Value: randomToken()},
} {
if _, err := h.use(c); !errors.Is(err, ErrNoSession) {
t.Errorf("%s: SessionFor = %v, want ErrNoSession", name, err)
}
}
if n := len(h.rows(t)); n != 1 {
t.Errorf("sessions has %d rows, want the signed-in session only", n)
}
}
func (h *sessionHarness) logout(cookies ...*http.Cookie) *http.Response {
req := httptest.NewRequest(http.MethodPost, LogoutPath, nil)
for _, c := range cookies {
req.AddCookie(c)
}
rec := httptest.NewRecorder()
h.router.ServeHTTP(rec, req)
return rec.Result()
}
func TestLogout(t *testing.T) {
t.Parallel()
h := newSessionHarness(t)
_, cookie := h.signIn(t)
_, other := h.signIn(t)
resp := h.logout(cookie)
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("logout status = %d, want 303", resp.StatusCode)
}
want := h.provider.issuer() + "/end-session/"
if got := resp.Header.Get("Location"); got != want || h.auth.EndSessionURL() != want {
t.Errorf("logout redirect = %q, EndSessionURL = %q, want %q", got, h.auth.EndSessionURL(), want)
}
raw := sessionSetCookie(t, resp)
assertSessionCookieAttributes(t, raw)
if !strings.HasPrefix(raw, SessionCookieName+"=;") || !strings.Contains(raw, "Max-Age=0") {
t.Errorf("logout Set-Cookie = %q, want the cookie cleared", raw)
}
rows := h.rows(t)
if len(rows) != 1 || rows[0].idHash != sha256Hex(other.Value) {
t.Errorf("after logout sessions = %+v, want only the other session", rows)
}
if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) {
t.Errorf("logged-out cookie: SessionFor = %v, want ErrNoSession", err)
}
if _, err := h.use(other); err != nil {
t.Errorf("other session: SessionFor = %v, want it untouched", err)
}
}
func TestLogoutWithoutSession(t *testing.T) {
t.Parallel()
h := newSessionHarness(t)
resp := h.logout()
if resp.StatusCode != http.StatusSeeOther || resp.Header.Get("Location") != h.provider.issuer()+"/end-session/" {
t.Errorf("logout = %d to %q, want 303 to the end session endpoint", resp.StatusCode, resp.Header.Get("Location"))
}
assertSessionCookieAttributes(t, sessionSetCookie(t, resp))
}
func TestLogoutIsPostOnly(t *testing.T) {
t.Parallel()
h := newSessionHarness(t)
_, cookie := h.signIn(t)
if resp := h.get(LogoutPath, cookie); resp.StatusCode != http.StatusMethodNotAllowed {
t.Errorf("GET %s status = %d, want 405", LogoutPath, resp.StatusCode)
}
if _, err := h.use(cookie); err != nil {
t.Errorf("after GET %s SessionFor = %v, want the session", LogoutPath, err)
}
}
func TestLogoutSessionError(t *testing.T) {
t.Parallel()
h := newHarness(t)
h.sessions.err = errors.New("database down")
rec := httptest.NewRecorder()
h.router.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, LogoutPath, nil))
if rec.Code != http.StatusInternalServerError {
t.Errorf("logout status = %d, want 500", rec.Code)
}
}
func TestNewRequiresEndSessionEndpoint(t *testing.T) {
t.Parallel()
p := newFakeProvider(t)
p.discoveryOverrides = map[string]any{"end_session_endpoint": nil}
_, err := New(context.Background(), Config{
Issuer: p.issuer(),
ClientID: testClientID,
ClientSecret: testClientSecret,
PublicURL: testPublicURL,
SecretKey: []byte("k"),
AllowedGroups: testAllowedGroups,
}, &memorySessions{}, slog.New(slog.DiscardHandler))
if err == nil || !strings.Contains(err.Error(), "end session endpoint") {
t.Errorf("New error = %v, want the missing end session endpoint named", err)
}
}
func TestNewSessionManagerValidates(t *testing.T) {
t.Parallel()
db := &store.Store{}
for _, tc := range []struct {
name string
db *store.Store
idle, max time.Duration
}{
{"nil store", nil, time.Hour, time.Hour},
{"zero idle", db, 0, time.Hour},
{"negative max", db, time.Hour, -time.Hour},
{"max below idle", db, 2 * time.Hour, time.Hour},
} {
if _, err := NewSessionManager(tc.db, tc.idle, tc.max); err == nil {
t.Errorf("%s: NewSessionManager succeeded", tc.name)
}
}
}
+47
View File
@@ -27,6 +27,16 @@ const (
EnvOIDCClientID = "MEALQUEST_OIDC_CLIENT_ID"
EnvOIDCClientSecretFile = "MEALQUEST_OIDC_CLIENT_SECRET_FILE"
EnvOIDCAllowedGroups = "MEALQUEST_OIDC_ALLOWED_GROUPS"
EnvSessionIdle = "MEALQUEST_SESSION_IDLE"
EnvSessionMax = "MEALQUEST_SESSION_MAX"
)
// Session lifetime defaults (D-004): long enough that the kitchen tablet
// stays signed in.
const (
DefaultSessionIdle = 720 * time.Hour // 30 days
DefaultSessionMax = 4320 * time.Hour // 180 days
)
// Config is the validated service configuration.
@@ -43,6 +53,11 @@ type Config struct {
OIDCClientSecret Secret
// OIDCAllowedGroups lists the groups whose members may sign in.
OIDCAllowedGroups []string
// SessionIdle ends a session left unused this long.
SessionIdle time.Duration
// SessionMax ends every session this long after sign-in, used or not.
SessionMax time.Duration
}
// PublicOrigin returns the scheme and host of PublicURL, the only parts of it
@@ -70,6 +85,12 @@ func load(lookup func(string) (string, bool)) (Config, error) {
OIDCClientID: r.str(EnvOIDCClientID),
OIDCClientSecret: r.secretFile(EnvOIDCClientSecretFile),
OIDCAllowedGroups: r.list(EnvOIDCAllowedGroups),
SessionIdle: r.sessionDuration(EnvSessionIdle, DefaultSessionIdle),
SessionMax: r.sessionDuration(EnvSessionMax, DefaultSessionMax),
}
if cfg.SessionIdle > 0 && cfg.SessionMax > 0 && cfg.SessionMax < cfg.SessionIdle {
r.fail(EnvSessionMax, "must be at least %s (%s)", EnvSessionIdle, cfg.SessionIdle)
}
if len(r.problems) > 0 {
return Config{}, &Error{Problems: r.problems}
@@ -243,6 +264,32 @@ func (r *reader) duration(name string) time.Duration {
if !ok {
return 0
}
return r.parseDuration(name, v)
}
// optionalDuration returns def when name is unset. A set value, even an empty
// one, must be a valid positive duration.
func (r *reader) optionalDuration(name string, def time.Duration) time.Duration {
v, ok := r.lookup(name)
if !ok {
return def
}
return r.parseDuration(name, v)
}
// sessionDuration is optionalDuration restricted to whole milliseconds:
// session expiry is stored as a store.Timestamp, which keeps millisecond
// precision, so a finer duration could not be honoured.
func (r *reader) sessionDuration(name string, def time.Duration) time.Duration {
d := r.optionalDuration(name, def)
if d%time.Millisecond != 0 {
r.fail(name, "duration must be a whole number of milliseconds, got %s", d)
return 0
}
return d
}
func (r *reader) parseDuration(name, v string) time.Duration {
d, err := time.ParseDuration(v)
if err != nil {
r.fail(name, "invalid duration %q (want e.g. 10s, 1m)", v)
+60
View File
@@ -246,6 +246,53 @@ func TestLoad(t *testing.T) {
env: with(map[string]string{EnvOIDCClientID: " "}),
badVars: []string{EnvOIDCClientID},
},
{
name: "session lifetimes set",
env: with(map[string]string{EnvSessionIdle: "24h", EnvSessionMax: "48h"}),
},
{
name: "session max equal to idle",
env: with(map[string]string{EnvSessionIdle: "24h", EnvSessionMax: "24h"}),
},
{
name: "invalid session idle",
env: with(map[string]string{EnvSessionIdle: "a month"}),
badVars: []string{EnvSessionIdle},
},
{
name: "empty session idle",
env: with(map[string]string{EnvSessionIdle: ""}),
badVars: []string{EnvSessionIdle},
},
{
name: "non-positive session max",
env: with(map[string]string{EnvSessionMax: "-1h"}),
badVars: []string{EnvSessionMax},
},
{
name: "whole-millisecond session lifetimes",
env: with(map[string]string{EnvSessionIdle: "1ms", EnvSessionMax: "2ms"}),
},
{
name: "sub-millisecond session idle",
env: with(map[string]string{EnvSessionIdle: "1500us", EnvSessionMax: "2ms"}),
badVars: []string{EnvSessionIdle},
},
{
name: "sub-millisecond session max",
env: with(map[string]string{EnvSessionIdle: "1ms", EnvSessionMax: "1ms500us"}),
badVars: []string{EnvSessionMax},
},
{
name: "session max below idle",
env: with(map[string]string{EnvSessionIdle: "48h", EnvSessionMax: "24h"}),
badVars: []string{EnvSessionMax},
},
{
name: "session max below default idle",
env: with(map[string]string{EnvSessionMax: "719h"}),
badVars: []string{EnvSessionMax},
},
{
name: "several invalid values are all named",
env: with(map[string]string{
@@ -351,6 +398,19 @@ func TestLoadValues(t *testing.T) {
if want := []string{"mealquest-admin", "family"}; !slices.Equal(cfg.OIDCAllowedGroups, want) {
t.Errorf("OIDCAllowedGroups = %q, want %q", cfg.OIDCAllowedGroups, want)
}
if cfg.SessionIdle != 720*time.Hour || cfg.SessionMax != 4320*time.Hour {
t.Errorf("SessionIdle, SessionMax = %v, %v, want defaults 720h, 4320h", cfg.SessionIdle, cfg.SessionMax)
}
env[EnvSessionIdle] = "12h"
env[EnvSessionMax] = "36h"
cfg, err = load(func(k string) (string, bool) { v, ok := env[k]; return v, ok })
if err != nil {
t.Fatalf("load() error = %v", err)
}
if cfg.SessionIdle != 12*time.Hour || cfg.SessionMax != 36*time.Hour {
t.Errorf("SessionIdle, SessionMax = %v, %v, want 12h, 36h", cfg.SessionIdle, cfg.SessionMax)
}
}
func TestSecretRedacted(t *testing.T) {
+17 -6
View File
@@ -22,9 +22,10 @@ type RouteMounter interface {
}
// NewRouter returns the HTTP handler for the service. version is reported by
// GET /health; auth mounts the /auth routes; assets is the web build served
// for every other GET.
func NewRouter(logger *slog.Logger, version string, auth RouteMounter, assets fs.FS) (http.Handler, error) {
// GET /health; auth mounts the /auth routes; sessions guards /api/v1 and
// /media; assets is the web build served for every other GET. /health, /auth
// and the web build are public.
func NewRouter(logger *slog.Logger, version string, auth RouteMounter, sessions SessionLookup, assets fs.FS) (http.Handler, error) {
notFound := apiErrorHandler(logger, http.StatusNotFound, "not_found", "not found")
methodNotAllowed := apiErrorHandler(logger, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
@@ -39,13 +40,23 @@ func NewRouter(logger *slog.Logger, version string, auth RouteMounter, assets fs
r.Get("/health", health(logger, version))
auth.Mount(r)
// The session middleware wraps the whole /api/v1 and /media subrouters
// rather than being added with Use: chi skips a subrouter's middleware
// when it has no routes, which would leave it unguarded.
signedIn := requireSession(logger, sessions)
apiV1, media := chi.NewRouter(), chi.NewRouter()
for _, sub := range []*chi.Mux{apiV1, media} {
sub.MethodNotAllowed(methodNotAllowed)
sub.NotFound(notFound)
}
r.Route("/api", func(r chi.Router) {
r.MethodNotAllowed(methodNotAllowed)
r.NotFound(notFound)
r.Mount("/v1", signedIn(apiV1))
})
// Catch-all for the PWA. Unknown /auth paths and /media (no routes yet)
// reach the frontend handler, which passes them to notFound rather than
// the SPA fallback.
r.Mount("/media", signedIn(media))
// Catch-all for the PWA. Unknown /auth paths reach the frontend handler,
// which passes them to notFound rather than the SPA fallback.
r.Method(http.MethodGet, "/*", web)
r.Method(http.MethodHead, "/*", web)
return r, nil
+7 -2
View File
@@ -27,7 +27,7 @@ func newTestRouter(t *testing.T, version string) http.Handler {
"index.html": {Data: []byte(fixtureIndex)},
"assets/index-AbC123.js": {Data: []byte(fixtureAsset)},
}
r, err := NewRouter(logger, version, stubAuth{}, assets)
r, err := NewRouter(logger, version, stubAuth{}, stubSessions{}, assets)
if err != nil {
t.Fatal(err)
}
@@ -126,6 +126,8 @@ func TestReservedPathsNotFound(t *testing.T) {
}{
{http.MethodGet, "/api"},
{http.MethodGet, "/api/unknown"},
{http.MethodGet, "/api/v1/unknown"},
{http.MethodPost, "/api/v1/unknown"},
{http.MethodPost, "/api/unknown"},
{http.MethodGet, "/auth/unknown"},
{http.MethodGet, "/media/photo.jpg"},
@@ -137,8 +139,11 @@ func TestReservedPathsNotFound(t *testing.T) {
t.Run(tt.method+" "+tt.path, func(t *testing.T) {
t.Parallel()
// Signed in, so /api/v1 and /media reach their routers.
req := httptest.NewRequest(tt.method, tt.path, nil)
req.AddCookie(validSessionCookie)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, httptest.NewRequest(tt.method, tt.path, nil))
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404", rec.Code)
+37
View File
@@ -0,0 +1,37 @@
package server
import (
"errors"
"log/slog"
"net/http"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
)
// SessionLookup finds the live session a request carries; it returns
// auth.ErrNoSession when there is none. *auth.SessionManager implements it.
type SessionLookup interface {
SessionFor(r *http.Request) (auth.Session, error)
}
// requireSession is the RequireSession middleware. It passes requests with a
// live session to next with the session's actor in the context (read it with
// auth.ActorFrom); others get 401 unauthenticated.
func requireSession(logger *slog.Logger, sessions SessionLookup) func(http.Handler) http.Handler {
unauthenticated := apiErrorHandler(logger, http.StatusUnauthorized, "unauthenticated", "sign in required")
internal := apiErrorHandler(logger, http.StatusInternalServerError, "internal", "internal error")
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
s, err := sessions.SessionFor(r)
switch {
case errors.Is(err, auth.ErrNoSession):
unauthenticated(w, r)
case err != nil:
logger.ErrorContext(r.Context(), "look up session", "error", err)
internal(w, r)
default:
next.ServeHTTP(w, r.WithContext(auth.WithActor(r.Context(), s.Actor())))
}
})
}
}
+151
View File
@@ -0,0 +1,151 @@
package server
import (
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"slices"
"testing"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
)
const (
unauthenticatedJSON = `{"error":{"code":"unauthenticated","message":"sign in required","retryable":false}}`
internalJSON = `{"error":{"code":"internal","message":"internal error","retryable":false}}`
)
var (
validSessionCookie = &http.Cookie{Name: auth.SessionCookieName, Value: "valid"}
brokenSessionCookie = &http.Cookie{Name: auth.SessionCookieName, Value: "broken"}
testSession = auth.Session{
Subject: "user-123",
Email: "cook@example.com",
Name: "Home Cook",
Groups: []string{"mealquest-admin"},
}
)
// stubSessions knows one valid session cookie; the broken cookie fails the
// lookup as a database error would.
type stubSessions struct{}
func (stubSessions) SessionFor(r *http.Request) (auth.Session, error) {
c, err := r.Cookie(auth.SessionCookieName)
switch {
case err != nil:
return auth.Session{}, auth.ErrNoSession
case c.Value == validSessionCookie.Value:
return testSession, nil
case c.Value == brokenSessionCookie.Value:
return auth.Session{}, errors.New("database down")
default:
return auth.Session{}, auth.ErrNoSession
}
}
func TestProtectedPathsRequireSession(t *testing.T) {
t.Parallel()
router := newTestRouter(t, "dev")
for _, tc := range []struct {
method, path string
cookie *http.Cookie
}{
{http.MethodGet, "/api/v1/settings/household", nil},
{http.MethodPatch, "/api/v1/settings/household", nil},
{http.MethodGet, "/api/v1", nil},
{http.MethodGet, "/media/photo.jpg", nil},
{http.MethodGet, "/api/v1/settings/household", &http.Cookie{Name: auth.SessionCookieName, Value: "unknown"}},
} {
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(tc.method, tc.path, nil)
if tc.cookie != nil {
req.AddCookie(tc.cookie)
}
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
t.Errorf("Content-Type = %q, want application/json", ct)
}
if got := rec.Body.String(); got != unauthenticatedJSON {
t.Errorf("body = %s, want %s", got, unauthenticatedJSON)
}
})
}
}
func TestPublicPathsNeedNoSession(t *testing.T) {
t.Parallel()
router := newTestRouter(t, "dev")
for _, path := range []string{"/health", "/auth/login", "/", "/assets/index-AbC123.js", "/quests/42"} {
t.Run(path, func(t *testing.T) {
t.Parallel()
rec := httptest.NewRecorder()
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
if rec.Code != http.StatusOK {
t.Errorf("GET %s without a cookie = %d, want 200", path, rec.Code)
}
})
}
}
func TestSessionLookupFailure(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(http.MethodGet, "/api/v1/settings/household", nil)
req.AddCookie(brokenSessionCookie)
rec := httptest.NewRecorder()
newTestRouter(t, "dev").ServeHTTP(rec, req)
if rec.Code != http.StatusInternalServerError || rec.Body.String() != internalJSON {
t.Errorf("response = %d %s, want 500 %s", rec.Code, rec.Body.String(), internalJSON)
}
}
func TestRequireSessionAttachesActor(t *testing.T) {
t.Parallel()
var got auth.Actor
var ok bool
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got, ok = auth.ActorFrom(r.Context())
w.WriteHeader(http.StatusNoContent)
})
h := requireSession(slog.New(slog.NewTextHandler(io.Discard, nil)), stubSessions{})(next)
req := httptest.NewRequest(http.MethodGet, "/api/v1/settings/household", nil)
req.AddCookie(validSessionCookie)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want the handler's 204", rec.Code)
}
if !ok || got.Subject != "user-123" || got.Email != "cook@example.com" || got.Name != "Home Cook" ||
!slices.Equal(got.Groups, []string{"mealquest-admin"}) {
t.Errorf("actor = %+v (ok=%v), want the session's household member", got, ok)
}
}
func TestRequireSessionStopsUnauthenticated(t *testing.T) {
t.Parallel()
called := false
next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true })
h := requireSession(slog.New(slog.NewTextHandler(io.Discard, nil)), stubSessions{})(next)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/settings/household", nil))
if called || rec.Code != http.StatusUnauthorized {
t.Errorf("handler called = %v, status = %d; want not called and 401", called, rec.Code)
}
}