Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15cc3c5ce4 | ||
|
|
b2201c5da1 | ||
|
|
f0bdd0ef40 |
@@ -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
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// Actor is the signed-in household member a request acts for. Handlers
|
||||
// record Actor.Subject as updated_by on edits.
|
||||
type Actor struct {
|
||||
Subject string
|
||||
Email string
|
||||
Name string
|
||||
Groups []string
|
||||
}
|
||||
|
||||
// Actor returns the household member s belongs to.
|
||||
func (s Session) Actor() Actor {
|
||||
return Actor{Subject: s.Subject, Email: s.Email, Name: s.Name, Groups: slices.Clone(s.Groups)}
|
||||
}
|
||||
|
||||
type sessionKey struct{}
|
||||
|
||||
// WithSession returns a copy of ctx carrying s. The session middleware calls
|
||||
// it for every authenticated request.
|
||||
func WithSession(ctx context.Context, s Session) context.Context {
|
||||
return context.WithValue(ctx, sessionKey{}, s)
|
||||
}
|
||||
|
||||
// SessionFrom returns the session WithSession stored in ctx. ok is false when
|
||||
// ctx has none, which behind the session middleware means a wiring bug.
|
||||
func SessionFrom(ctx context.Context) (s Session, ok bool) {
|
||||
s, ok = ctx.Value(sessionKey{}).(Session)
|
||||
return s, ok
|
||||
}
|
||||
|
||||
// ActorFrom returns the actor of the session WithSession stored in ctx. ok is
|
||||
// false when ctx has none.
|
||||
func ActorFrom(ctx context.Context) (actor Actor, ok bool) {
|
||||
s, ok := SessionFrom(ctx)
|
||||
if !ok {
|
||||
return Actor{}, false
|
||||
}
|
||||
return s.Actor(), true
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestActorContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
if _, ok := ActorFrom(context.Background()); ok {
|
||||
t.Error("ActorFrom found an actor in an empty context")
|
||||
}
|
||||
if _, ok := SessionFrom(context.Background()); ok {
|
||||
t.Error("SessionFrom found a session in an empty context")
|
||||
}
|
||||
s := Session{Subject: "user-123", Email: "cook@example.com", Name: "Home Cook", Groups: []string{"mealquest-admin"}, CSRFToken: "t"}
|
||||
ctx := WithSession(context.Background(), s)
|
||||
gotSession, ok := SessionFrom(ctx)
|
||||
if !ok || gotSession.Subject != s.Subject || gotSession.CSRFToken != s.CSRFToken {
|
||||
t.Errorf("SessionFrom = %+v (ok=%v), want %+v", gotSession, ok, s)
|
||||
}
|
||||
got, ok := ActorFrom(ctx)
|
||||
if !ok || got.Subject != s.Subject || got.Email != s.Email || got.Name != s.Name || !slices.Equal(got.Groups, s.Groups) {
|
||||
t.Errorf("ActorFrom = %+v (ok=%v), want the session's actor", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionActor(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := Session{Subject: "user-123", Email: "cook@example.com", Name: "Home Cook", Groups: []string{"mealquest-admin"}, CSRFToken: "t"}
|
||||
a := s.Actor()
|
||||
if a.Subject != s.Subject || a.Email != s.Email || a.Name != s.Name || !slices.Equal(a.Groups, s.Groups) {
|
||||
t.Errorf("Actor = %+v, want the session's identity", a)
|
||||
}
|
||||
a.Groups[0] = "changed"
|
||||
if s.Groups[0] != "mealquest-admin" {
|
||||
t.Error("Actor shares the session's groups slice")
|
||||
}
|
||||
}
|
||||
+115
-16
@@ -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
@@ -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)
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// signInWith completes a login whose ID token has claims edited by edit.
|
||||
func (h *harness) signInWith(t *testing.T, edit func(claims map[string]any)) *http.Response {
|
||||
t.Helper()
|
||||
l := h.login(t, LoginPath)
|
||||
c := h.provider.claims(l.query.Get("nonce"))
|
||||
edit(c)
|
||||
return h.callback(l, l.query.Get("state"), h.provider.signRS256(c))
|
||||
}
|
||||
|
||||
// assertRefused checks resp is the refused-sign-in page and starts no
|
||||
// session.
|
||||
func assertRefused(t *testing.T, resp *http.Response) {
|
||||
t.Helper()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("callback status = %d, want 403", resp.StatusCode)
|
||||
}
|
||||
if ct := resp.Header.Get("Content-Type"); ct != "text/html; charset=utf-8" {
|
||||
t.Errorf("Content-Type = %q, want text/html; charset=utf-8", ct)
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"This kitchen is for the household only",
|
||||
`<form method="post" action="/auth/logout">`,
|
||||
"Sign out",
|
||||
} {
|
||||
if !strings.Contains(string(body), want) {
|
||||
t.Errorf("refused page lacks %q:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
for _, c := range resp.Cookies() {
|
||||
// Clearing a session cookie (negative Max-Age) is expected.
|
||||
if (c.Name == SessionCookieName || c.Name == "test_session") && c.MaxAge >= 0 {
|
||||
t.Errorf("refused sign-in set session cookie %s", c.Name)
|
||||
}
|
||||
}
|
||||
assertLoginCookieCleared(t, resp)
|
||||
}
|
||||
|
||||
func TestGroupGateMember(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Any one allowed group is enough, alongside groups that are not allowed.
|
||||
h := newHarness(t)
|
||||
resp := h.signInWith(t, func(c map[string]any) { c["groups"] = []string{"guests", "mealquest-family"} })
|
||||
if resp.StatusCode != http.StatusSeeOther {
|
||||
t.Fatalf("callback status = %d, want 303", resp.StatusCode)
|
||||
}
|
||||
if started := h.sessions.started(); len(started) != 1 || !slices.Equal(started[0].Groups, []string{"guests", "mealquest-family"}) {
|
||||
t.Errorf("sessions started = %+v, want one with the ID token groups", started)
|
||||
}
|
||||
if n := h.provider.userinfoRequests(); n != 0 {
|
||||
t.Errorf("userinfo requested %d times, want 0 when the ID token has groups", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupGateRefused(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
groups any // ID token groups claim; nil removes it
|
||||
userinfo map[string]any
|
||||
}{
|
||||
{name: "non-member", groups: []string{"guests", "MEALQUEST-ADMIN"}},
|
||||
{name: "empty groups claim", groups: []string{}},
|
||||
{name: "groups claim missing from ID token and userinfo", groups: nil},
|
||||
{
|
||||
name: "non-member in userinfo",
|
||||
groups: nil,
|
||||
userinfo: map[string]any{"sub": "user-123", "groups": []string{"guests"}},
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
if tc.userinfo != nil {
|
||||
h.provider.setUserinfo(tc.userinfo)
|
||||
}
|
||||
resp := h.signInWith(t, func(c map[string]any) {
|
||||
if tc.groups == nil {
|
||||
delete(c, "groups")
|
||||
} else {
|
||||
c["groups"] = tc.groups
|
||||
}
|
||||
})
|
||||
assertRefused(t, resp)
|
||||
if started := h.sessions.started(); len(started) != 0 {
|
||||
t.Errorf("sessions started = %+v, want none", started)
|
||||
}
|
||||
if !strings.Contains(h.logs.String(), "sign-in refused") {
|
||||
t.Errorf("refusal not logged:\n%s", h.logs.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupGateUsesUserinfoWhenIDTokenHasNoGroups(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
h.provider.setUserinfo(map[string]any{"sub": "user-123", "groups": []string{"mealquest-admin"}})
|
||||
resp := h.signInWith(t, func(c map[string]any) { delete(c, "groups") })
|
||||
if resp.StatusCode != http.StatusSeeOther {
|
||||
t.Fatalf("callback status = %d, want 303", resp.StatusCode)
|
||||
}
|
||||
if started := h.sessions.started(); len(started) != 1 || !slices.Equal(started[0].Groups, []string{"mealquest-admin"}) {
|
||||
t.Errorf("sessions started = %+v, want one with the userinfo groups", started)
|
||||
}
|
||||
if n := h.provider.userinfoRequests(); n != 1 {
|
||||
t.Errorf("userinfo requested %d times, want 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupGateUserinfoFailures(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
userinfo map[string]any
|
||||
}{
|
||||
{"subject mismatch", map[string]any{"sub": "someone-else", "groups": []string{"mealquest-admin"}}},
|
||||
{"malformed groups", map[string]any{"sub": "user-123", "groups": "mealquest-admin"}},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
h.provider.setUserinfo(tc.userinfo)
|
||||
resp := h.signInWith(t, func(c map[string]any) { delete(c, "groups") })
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("callback status = %d, want 401", resp.StatusCode)
|
||||
}
|
||||
if started := h.sessions.started(); len(started) != 0 {
|
||||
t.Errorf("sessions started = %+v, want none", started)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A failed userinfo request fails the sign-in without logging the
|
||||
// endpoint's response, which may echo credentials.
|
||||
func TestGroupGateUserinfoErrorNotLogged(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
h.provider.mu.Lock()
|
||||
h.provider.userinfoReply = &tokenReply{status: http.StatusInternalServerError, body: `{"error":"leaked-s3cret"}`}
|
||||
h.provider.mu.Unlock()
|
||||
resp := h.signInWith(t, func(c map[string]any) { delete(c, "groups") })
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("callback status = %d, want 401", resp.StatusCode)
|
||||
}
|
||||
if started := h.sessions.started(); len(started) != 0 {
|
||||
t.Errorf("sessions started = %+v, want none", started)
|
||||
}
|
||||
logs := h.logs.String()
|
||||
if !strings.Contains(logs, "userinfo") || strings.Contains(logs, "leaked-s3cret") {
|
||||
t.Errorf("logs = %s, want the userinfo failure without the response body", logs)
|
||||
}
|
||||
}
|
||||
|
||||
// Membership is checked at every login: after removal from the group, the
|
||||
// next login is refused and starts no session.
|
||||
func TestGroupGateRemovedMemberRefusedAtNextLogin(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newSessionHarness(t)
|
||||
h.signIn(t)
|
||||
if n := len(h.rows(t)); n != 1 {
|
||||
t.Fatalf("sessions after first login = %d, want 1", n)
|
||||
}
|
||||
|
||||
resp := h.signInWith(t, func(c map[string]any) { c["groups"] = []string{"former-household"} })
|
||||
assertRefused(t, resp)
|
||||
if n := len(h.rows(t)); n != 1 {
|
||||
t.Errorf("sessions after refused login = %d, want still 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
// A refused sign-in from a browser that still holds a session ends that
|
||||
// session and clears its cookie.
|
||||
func TestGroupGateRefusalEndsExistingSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newSessionHarness(t)
|
||||
_, cookie := h.signIn(t)
|
||||
|
||||
l := h.login(t, LoginPath)
|
||||
c := h.provider.claims(l.query.Get("nonce"))
|
||||
c["groups"] = []string{"former-household"}
|
||||
code := h.provider.authorize(l.query.Get("code_challenge"), h.provider.signRS256(c))
|
||||
q := url.Values{"code": {code}, "state": {l.query.Get("state")}}
|
||||
resp := h.get(CallbackPath+"?"+q.Encode(), l.cookie, cookie)
|
||||
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("callback status = %d, want 403", resp.StatusCode)
|
||||
}
|
||||
if n := len(h.rows(t)); n != 0 {
|
||||
t.Errorf("sessions after refused login = %d, want 0", n)
|
||||
}
|
||||
if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) {
|
||||
t.Errorf("old session lookup error = %v, want ErrNoSession", err)
|
||||
}
|
||||
cleared := false
|
||||
for _, c := range resp.Cookies() {
|
||||
cleared = cleared || (c.Name == SessionCookieName && c.MaxAge < 0)
|
||||
}
|
||||
if !cleared {
|
||||
t.Error("refused sign-in did not clear the session cookie")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupGateRefusalEndSessionError(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
h.sessions.err = errors.New("database down")
|
||||
resp := h.signInWith(t, func(c map[string]any) { c["groups"] = []string{"guests"} })
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Errorf("callback status = %d, want 500", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRequiresAllowedGroups(t *testing.T) {
|
||||
t.Parallel()
|
||||
p := newFakeProvider(t)
|
||||
_, err := New(context.Background(), Config{
|
||||
Issuer: p.issuer(),
|
||||
ClientID: testClientID,
|
||||
ClientSecret: testClientSecret,
|
||||
PublicURL: testPublicURL,
|
||||
SecretKey: []byte("k"),
|
||||
}, &memorySessions{}, slog.New(slog.DiscardHandler))
|
||||
if err == nil || !strings.Contains(err.Error(), "allowed group") {
|
||||
t.Errorf("New error = %v, want missing allowed groups named", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRequiresUserinfoEndpoint(t *testing.T) {
|
||||
t.Parallel()
|
||||
p := newFakeProvider(t)
|
||||
p.discoveryOverrides = map[string]any{"userinfo_endpoint": nil}
|
||||
_, err := New(context.Background(), Config{
|
||||
Issuer: p.issuer(),
|
||||
ClientID: testClientID,
|
||||
ClientSecret: testClientSecret,
|
||||
PublicURL: testPublicURL,
|
||||
SecretKey: []byte("k"),
|
||||
AllowedGroups: testAllowedGroups,
|
||||
}, &memorySessions{}, slog.New(slog.DiscardHandler))
|
||||
if err == nil || !strings.Contains(err.Error(), "userinfo endpoint") {
|
||||
t.Errorf("New error = %v, want the missing userinfo endpoint named", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// refusedPage is shown when a sign-in succeeds at the provider but the
|
||||
// account is not in the household's group. Signing out ends the provider
|
||||
// session too, so another account can sign in.
|
||||
const refusedPage = `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>MealQuest</title>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>This kitchen is for the household only</h1>
|
||||
<p>The account you signed in with is not part of our kitchen.</p>
|
||||
<form method="post" action="` + LogoutPath + `">
|
||||
<button type="submit">Sign out</button>
|
||||
</form>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
// refuse writes the refused-sign-in page with 403.
|
||||
func (a *Auth) refuse(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
if _, err := io.WriteString(w, refusedPage); err != nil {
|
||||
a.logger.WarnContext(r.Context(), "write refused sign-in page", "error", err)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
|
||||
)
|
||||
|
||||
// CSRFHeader carries the session's CSRF token, which the client reads from
|
||||
// GET /api/v1/me.
|
||||
const CSRFHeader = "X-MealQuest-CSRF"
|
||||
|
||||
// requireCSRF passes GET, HEAD and OPTIONS 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, http.MethodHead, http.MethodOptions:
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
s, ok := auth.SessionFrom(r.Context())
|
||||
if !ok || s.CSRFToken == "" {
|
||||
// An empty token would match a missing header.
|
||||
logger.ErrorContext(r.Context(), "csrf check without a session token", "session", ok)
|
||||
internal(w, r)
|
||||
return
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(r.Header.Get(CSRFHeader)), []byte(s.CSRFToken)) != 1 {
|
||||
forbidden(w, r)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
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, ""},
|
||||
// HEAD and OPTIONS pass the check and reach the router, which
|
||||
// answers neither for /api/v1/me.
|
||||
{"HEAD without header", http.MethodHead, "/api/v1/me", nil, http.StatusMethodNotAllowed, ""},
|
||||
{"OPTIONS without header", http.MethodOptions, "/api/v1/me", nil, http.StatusMethodNotAllowed, methodNotAllowedJSON},
|
||||
} {
|
||||
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},
|
||||
{"no session in context", http.MethodPost, nil, "", http.StatusInternalServerError},
|
||||
{"session without token", http.MethodPost, &auth.Session{Subject: "user-123"}, "", http.StatusInternalServerError},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
called := false
|
||||
h := requireCSRF(logger)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
called = true
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
ctx := context.Background()
|
||||
if tc.session != nil {
|
||||
ctx = auth.WithSession(ctx, *tc.session)
|
||||
}
|
||||
req := httptest.NewRequestWithContext(ctx, tc.method, "/api/v1/anything", nil)
|
||||
if tc.header != "" {
|
||||
req.Header.Set(CSRFHeader, tc.header)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != tc.wantStatus {
|
||||
t.Errorf("status = %d, want %d", rec.Code, tc.wantStatus)
|
||||
}
|
||||
if called != (tc.wantStatus == http.StatusNoContent) {
|
||||
t.Errorf("handler called = %v with status %d", called, rec.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMe(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
|
||||
req.AddCookie(validSessionCookie)
|
||||
rec := httptest.NewRecorder()
|
||||
newTestRouter(t, "dev").ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body %s)", rec.Code, rec.Body.String())
|
||||
}
|
||||
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
|
||||
t.Errorf("Content-Type = %q, want application/json", ct)
|
||||
}
|
||||
if cc := rec.Header().Get("Cache-Control"); cc != "no-store" {
|
||||
t.Errorf("Cache-Control = %q, want no-store", cc)
|
||||
}
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode %s: %v", rec.Body.String(), err)
|
||||
}
|
||||
want := map[string]any{
|
||||
"name": "Home Cook",
|
||||
"email": "cook@example.com",
|
||||
"household": map[string]any{"label": "Our kitchen"},
|
||||
"csrfToken": "csrf-token-123",
|
||||
}
|
||||
gotJSON, _ := json.Marshal(got)
|
||||
wantJSON, _ := json.Marshal(want)
|
||||
if string(gotJSON) != string(wantJSON) {
|
||||
t.Errorf("body = %s, want %s", gotJSON, wantJSON)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeNeedsSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
newTestRouter(t, "dev").ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/me", nil))
|
||||
if rec.Code != http.StatusUnauthorized || rec.Body.String() != unauthenticatedJSON {
|
||||
t.Errorf("response = %d %s, want 401 %s", rec.Code, rec.Body.String(), unauthenticatedJSON)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
|
||||
)
|
||||
|
||||
// householdLabel is the single shared household identity.
|
||||
const householdLabel = "Our kitchen"
|
||||
|
||||
type meResponse struct {
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Household householdInfo `json:"household"`
|
||||
CSRFToken string `json:"csrfToken"`
|
||||
}
|
||||
|
||||
type householdInfo struct {
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
// me answers GET /api/v1/me with the signed-in member, the household and the
|
||||
// session's CSRF token for CSRFHeader.
|
||||
func me(logger *slog.Logger) http.HandlerFunc {
|
||||
internal := apiErrorHandler(logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
s, ok := auth.SessionFrom(r.Context())
|
||||
if !ok {
|
||||
logger.ErrorContext(r.Context(), "GET /api/v1/me without a session")
|
||||
internal(w, r)
|
||||
return
|
||||
}
|
||||
body, err := json.Marshal(meResponse{
|
||||
Name: s.Name,
|
||||
Email: s.Email,
|
||||
Household: householdInfo{Label: householdLabel},
|
||||
CSRFToken: s.CSRFToken,
|
||||
})
|
||||
if err != nil {
|
||||
panic(err) // string fields cannot fail to marshal
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if _, err := w.Write(body); err != nil {
|
||||
logger.WarnContext(r.Context(), "write me response", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,9 +22,11 @@ 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. Requests to /api/v1 other than GET, HEAD and
|
||||
// OPTIONS also need the session's CSRF token in CSRFHeader.
|
||||
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 +41,25 @@ 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)
|
||||
csrf := requireCSRF(logger)
|
||||
apiV1, media := chi.NewRouter(), chi.NewRouter()
|
||||
for _, sub := range []*chi.Mux{apiV1, media} {
|
||||
sub.MethodNotAllowed(methodNotAllowed)
|
||||
sub.NotFound(notFound)
|
||||
}
|
||||
apiV1.Get("/me", me(logger))
|
||||
r.Route("/api", func(r chi.Router) {
|
||||
r.MethodNotAllowed(methodNotAllowed)
|
||||
r.NotFound(notFound)
|
||||
r.Mount("/v1", signedIn(csrf(apiV1)))
|
||||
})
|
||||
// Catch-all for the PWA. Unknown /auth paths and /media (no routes yet)
|
||||
// reach the frontend handler, which passes them to notFound rather than
|
||||
// the SPA fallback.
|
||||
r.Mount("/media", signedIn(media))
|
||||
// Catch-all for the PWA. Unknown /auth paths reach the frontend handler,
|
||||
// which passes them to notFound rather than the SPA fallback.
|
||||
r.Method(http.MethodGet, "/*", web)
|
||||
r.Method(http.MethodHead, "/*", web)
|
||||
return r, nil
|
||||
|
||||
@@ -27,7 +27,7 @@ func newTestRouter(t *testing.T, version string) http.Handler {
|
||||
"index.html": {Data: []byte(fixtureIndex)},
|
||||
"assets/index-AbC123.js": {Data: []byte(fixtureAsset)},
|
||||
}
|
||||
r, err := NewRouter(logger, version, stubAuth{}, assets)
|
||||
r, err := NewRouter(logger, version, stubAuth{}, stubSessions{}, assets)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -126,6 +126,8 @@ func TestReservedPathsNotFound(t *testing.T) {
|
||||
}{
|
||||
{http.MethodGet, "/api"},
|
||||
{http.MethodGet, "/api/unknown"},
|
||||
{http.MethodGet, "/api/v1/unknown"},
|
||||
{http.MethodPost, "/api/v1/unknown"},
|
||||
{http.MethodPost, "/api/unknown"},
|
||||
{http.MethodGet, "/auth/unknown"},
|
||||
{http.MethodGet, "/media/photo.jpg"},
|
||||
@@ -137,8 +139,13 @@ func TestReservedPathsNotFound(t *testing.T) {
|
||||
t.Run(tt.method+" "+tt.path, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Signed in with the CSRF token, so /api/v1 and /media reach
|
||||
// their routers.
|
||||
req := httptest.NewRequest(tt.method, tt.path, nil)
|
||||
req.AddCookie(validSessionCookie)
|
||||
req.Header.Set(CSRFHeader, testSession.CSRFToken)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, httptest.NewRequest(tt.method, tt.path, nil))
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("status = %d, want 404", rec.Code)
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
|
||||
)
|
||||
|
||||
// SessionLookup finds the live session a request carries; it returns
|
||||
// auth.ErrNoSession when there is none. *auth.SessionManager implements it.
|
||||
type SessionLookup interface {
|
||||
SessionFor(r *http.Request) (auth.Session, error)
|
||||
}
|
||||
|
||||
// requireSession is the RequireSession middleware. It passes requests with a
|
||||
// live session to next with the session in the context (read it with
|
||||
// auth.SessionFrom or auth.ActorFrom); others get 401 unauthenticated.
|
||||
func requireSession(logger *slog.Logger, sessions SessionLookup) func(http.Handler) http.Handler {
|
||||
unauthenticated := apiErrorHandler(logger, http.StatusUnauthorized, "unauthenticated", "sign in required")
|
||||
internal := apiErrorHandler(logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
s, err := sessions.SessionFor(r)
|
||||
switch {
|
||||
case errors.Is(err, auth.ErrNoSession):
|
||||
unauthenticated(w, r)
|
||||
case err != nil:
|
||||
logger.ErrorContext(r.Context(), "look up session", "error", err)
|
||||
internal(w, r)
|
||||
default:
|
||||
next.ServeHTTP(w, r.WithContext(auth.WithSession(r.Context(), s)))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
|
||||
)
|
||||
|
||||
const (
|
||||
unauthenticatedJSON = `{"error":{"code":"unauthenticated","message":"sign in required","retryable":false}}`
|
||||
internalJSON = `{"error":{"code":"internal","message":"internal error","retryable":false}}`
|
||||
)
|
||||
|
||||
var (
|
||||
validSessionCookie = &http.Cookie{Name: auth.SessionCookieName, Value: "valid"}
|
||||
brokenSessionCookie = &http.Cookie{Name: auth.SessionCookieName, Value: "broken"}
|
||||
testSession = auth.Session{
|
||||
Subject: "user-123",
|
||||
Email: "cook@example.com",
|
||||
Name: "Home Cook",
|
||||
Groups: []string{"mealquest-admin"},
|
||||
CSRFToken: "csrf-token-123",
|
||||
}
|
||||
)
|
||||
|
||||
// stubSessions knows one valid session cookie; the broken cookie fails the
|
||||
// lookup as a database error would.
|
||||
type stubSessions struct{}
|
||||
|
||||
func (stubSessions) SessionFor(r *http.Request) (auth.Session, error) {
|
||||
c, err := r.Cookie(auth.SessionCookieName)
|
||||
switch {
|
||||
case err != nil:
|
||||
return auth.Session{}, auth.ErrNoSession
|
||||
case c.Value == validSessionCookie.Value:
|
||||
return testSession, nil
|
||||
case c.Value == brokenSessionCookie.Value:
|
||||
return auth.Session{}, errors.New("database down")
|
||||
default:
|
||||
return auth.Session{}, auth.ErrNoSession
|
||||
}
|
||||
}
|
||||
|
||||
func TestProtectedPathsRequireSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
router := newTestRouter(t, "dev")
|
||||
for _, tc := range []struct {
|
||||
method, path string
|
||||
cookie *http.Cookie
|
||||
}{
|
||||
{http.MethodGet, "/api/v1/settings/household", nil},
|
||||
{http.MethodPatch, "/api/v1/settings/household", nil},
|
||||
{http.MethodGet, "/api/v1", nil},
|
||||
{http.MethodGet, "/media/photo.jpg", nil},
|
||||
{http.MethodGet, "/api/v1/settings/household", &http.Cookie{Name: auth.SessionCookieName, Value: "unknown"}},
|
||||
} {
|
||||
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
req := httptest.NewRequest(tc.method, tc.path, nil)
|
||||
if tc.cookie != nil {
|
||||
req.AddCookie(tc.cookie)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want 401", rec.Code)
|
||||
}
|
||||
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
|
||||
t.Errorf("Content-Type = %q, want application/json", ct)
|
||||
}
|
||||
if got := rec.Body.String(); got != unauthenticatedJSON {
|
||||
t.Errorf("body = %s, want %s", got, unauthenticatedJSON)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicPathsNeedNoSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
router := newTestRouter(t, "dev")
|
||||
for _, path := range []string{"/health", "/auth/login", "/", "/assets/index-AbC123.js", "/quests/42"} {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("GET %s without a cookie = %d, want 200", path, rec.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionLookupFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/settings/household", nil)
|
||||
req.AddCookie(brokenSessionCookie)
|
||||
rec := httptest.NewRecorder()
|
||||
newTestRouter(t, "dev").ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusInternalServerError || rec.Body.String() != internalJSON {
|
||||
t.Errorf("response = %d %s, want 500 %s", rec.Code, rec.Body.String(), internalJSON)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireSessionAttachesActor(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var got auth.Actor
|
||||
var ok bool
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
got, ok = auth.ActorFrom(r.Context())
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
h := requireSession(slog.New(slog.NewTextHandler(io.Discard, nil)), stubSessions{})(next)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/settings/household", nil)
|
||||
req.AddCookie(validSessionCookie)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want the handler's 204", rec.Code)
|
||||
}
|
||||
if !ok || got.Subject != "user-123" || got.Email != "cook@example.com" || got.Name != "Home Cook" ||
|
||||
!slices.Equal(got.Groups, []string{"mealquest-admin"}) {
|
||||
t.Errorf("actor = %+v (ok=%v), want the session's household member", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireSessionStopsUnauthenticated(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
called := false
|
||||
next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true })
|
||||
h := requireSession(slog.New(slog.NewTextHandler(io.Discard, nil)), stubSessions{})(next)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/settings/household", nil))
|
||||
|
||||
if called || rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("handler called = %v, status = %d; want not called and 401", called, rec.Code)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user