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.

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.

AttributeWhy it matters
HttpOnlyKeeps the value out of reach of scripts, so a cross-site scripting bug cannot read the session
SecureRefuses to send the cookie over plain HTTP. Set it wherever the service is served over TLS
SameSiteLax stops the cookie riding along on cross-site requests, which is most of what CSRF needs
PathNarrows where the cookie is sent. / is right for a session, narrower is better for anything else
MaxAgeBounds 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.

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.

Open source under MIT / Apache-2.0.Built on net/http, and nothing else.