Cookies
A cookie is bound like any other parameter.
// SessionCookies groups the cookies the browser sends back.
type SessionCookies struct {
// SessionID identifies the signed-in reader and must be present.
SessionID string `cookie:"session_id" required:"true" doc:"The reader's session"`
// The trackers are declared so they are documented and so a reader can be
// told what is being read, not because the feed needs them.
FatebookTracker string `cookie:"fatebook_tracker" doc:"Third party analytics cookie, if the reader accepted one"`
GoogallTracker string `cookie:"googall_tracker" doc:"Third party analytics cookie, if the reader accepted one"`
}
Embed that group into any input that needs it:
type FeedIn struct {
ClientHeaders
SessionCookies
Limit int `query:"limit" default:"20" doc:"How many entries to return"`
}
A cookie is optional unless the field says required:"true". A missing required cookie is
a 422 naming it:
{ "field": "session_id", "location": "cookie", "issue": "is required" }
Cookies are converted by the same setters every other parameter uses, so a cookie holding a number or a UUID can be bound as one.
type PreferenceCookies struct {
Theme string `cookie:"theme" default:"system"`
PageSize int `cookie:"page_size" default:"25"`
DeviceID uuid.UUID `cookie:"device_id"`
}
Validate them the way you validate anything else:
func (in *FeedIn) Validate(v *muzak.Validation) {
v.String(&in.SessionID).Trim().MinLen(4).MaxLen(64)
v.String(&in.Theme).OneOf("system", "light", "dark")
}
Reading one directly
cookie, err := ctx.Cookie("theme")
if errors.Is(err, http.ErrNoCookie) {
// nothing was sent under that name
}
That is what a provider reading a cookie for several routes does, because it runs before binding and serves every route that declares it:
// Theme is the reader's display preference, read once for every route that
// needs it.
type Theme string
func GetTheme(ctx *muzak.Context) (Theme, error) {
if cookie, err := ctx.Cookie("theme"); err == nil && cookie.Value == "dark" {
return "dark", nil
}
return "light", nil
}
The session of AppOptions.Sessions is not read this way. Its cookie carries the session
encrypted, or an identifier into a store, and neither means anything to a handler: read the
session through ctx.Session(), as the GetCaller provider in
Dependencies does.
Setting a cookie
Muzak writes the cookie of AppOptions.Sessions itself, as described below. Every other
cookie is set with SetCookie, here an identifier for a session an application keeps by hand:
session := uuid.NewV4().String()
ctx.SetCookie(&http.Cookie{
Name: "session_id",
Value: session,
Path: "/",
// HttpOnly keeps the session out of reach of scripts, and SameSite keeps
// it off cross-site requests. Secure belongs here too once this is served
// over TLS, which is why it is named rather than omitted.
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: false,
MaxAge: 3600,
})
SetCookie adds a Set-Cookie header for the cookie as given. Muzak does not modify it:
the attributes are the caller's to choose, and choosing them is the whole security
decision.
| Attribute | Why it matters |
|---|---|
HttpOnly | Keeps the value out of reach of scripts, so a cross-site scripting bug cannot read the session |
Secure | Refuses to send the cookie over plain HTTP. Set it wherever the service is served over TLS |
SameSite | Lax stops the cookie riding along on cross-site requests, which is most of what CSRF needs |
Path | Narrows where the cookie is sent. / is right for a session, narrower is better for anything else |
MaxAge | Bounds how long the credential is useful. A session with no expiry is a credential with no expiry |
A session cookie is exactly the kind of ambient authority a cross-origin WebSocket
handshake can borrow, which is why the origin check in
WebSockets exists and why AppOptions.CORS cannot
substitute for it.
A session identifier set by hand, as above, is a credential you then store, look up and expire
yourself. AppOptions.Sessions does that for you: the session lives in a cookie it
encrypts, or in a store the cookie points into, named __Host-session and sent HttpOnly,
Secure and SameSite=Lax by default. See Sessions. It requires
AppOptions.CrossOriginProtection, which refuses a state-changing request a browser sends from
another origin, including a form posted from a sibling subdomain, which SameSite lets through
because it is the same site. See
Cross-Origin Requests.
Clearing a cookie
Send it back with an expiry in the past and the same Path, so the browser drops it.
ctx.SetCookie(&http.Cookie{
Name: "session_id",
Value: "",
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: -1,
})
A session of AppOptions.Sessions is not cleared by hand. Signing out calls
ctx.Session().Destroy(), which removes the cookie the same way and, with a server-side store,
deletes the entry too, so a copy of the cookie taken earlier is worthless. See
Sessions.
Several cookies on one response
SetCookie appends rather than replacing, so more than one is fine.
ctx.SetCookie(&http.Cookie{Name: "session_id", Value: session, Path: "/", HttpOnly: true})
ctx.SetCookie(&http.Cookie{Name: "theme", Value: in.Theme, Path: "/", MaxAge: 31536000})
Cookies in logs
cookie and set-cookie are in muzak.DefaultRedactedKeys, so an attribute under either
key is replaced with [redacted] before a record is written. The access log records no
headers at all. See Logging.
Cookies in tests
The test client keeps a cookie jar, so a sign-in followed by a call that needs the session works the way it would in a browser.
func TestLoginThenLogout(t *testing.T) {
client := testclient.New(t, buildApp())
form := url.Values{"username": {"muzak"}, "password": {"correct-horse-battery"}}
client.Post("/login/", testclient.Body(
"application/x-www-form-urlencoded", strings.NewReader(form.Encode()))).
AssertStatus(http.StatusOK)
// The jar carries the __Host-session cookie set above, so the sign-out
// ends that session, and its response removes the cookie.
res := client.Post("/logout/")
res.AssertStatus(http.StatusOK)
if len(res.Cookies) != 1 || res.Cookies[0].MaxAge >= 0 {
t.Errorf("want the session cookie removed, got %v", res.Cookies)
}
}
The application listens on a loopback address, which the jar treats as a secure origin as
browsers do, so the Secure session cookie is sent back over the test's plain HTTP.
testclient.WithoutCookies() disables the jar when each request should be independent, and
testclient.Cookie(c) sends one cookie on a single request.
Where to go next
Sessions covers a session cookie Muzak encrypts and expires for you, Authentication covers verifying who sent a request, and Forms and HTML covers the sign-in form that sets one.