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("session")
if errors.Is(err, http.ErrNoCookie) {
// nothing was sent under that name
}
That is what a dependency resolving a session does, because it runs before binding and serves several routes at once:
func GetSessionOrToken(ctx *muzak.Context) (SessionOrToken, error) {
if cookie, err := ctx.Cookie("session"); err == nil && cookie.Value != "" {
return SessionOrToken{Value: cookie.Value, FromCookie: true}, nil
}
if token := ctx.Query("token"); token != "" {
return SessionOrToken{Value: token}, nil
}
return SessionOrToken{}, muzak.Unauthorized("a session cookie or a token query parameter is required")
}
Setting a cookie
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.
Clearing a cookie
Send it back with an expiry in the past and the same Path, so the browser drops it.
func Logout(ctx *muzak.Context, _ muzak.Empty) (muzak.Empty, error) {
ctx.SetCookie(&http.Cookie{
Name: "session_id",
Value: "",
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: -1,
})
return muzak.Empty{}, nil
}
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 login followed by an authenticated call works the way it would in a browser.
func TestLoginThenRead(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 session_id cookie set above.
client.Get("/feed").AssertStatus(http.StatusOK)
}
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
Authentication covers what goes in a session cookie, and Forms and HTML covers the sign-in form that sets one.