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")
}
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.

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.

Open source under MIT / Apache-2.0 · sustained by the people who ship on it.Built on net/http, and nothing else.