Authentication

Authentication in Muzak is a dependency. A guard rejects a request that cannot prove who it is; a provider resolves the caller into a typed value the handler reads with muzak.From. Both run before binding and before the handler, so an unauthenticated caller gets 401 rather than a map of your schema.

Shared secrets

For an internal service, a webhook receiver or an admin subtree, the credential is one secret both sides know. Two guards ship with the framework.

// An Authorization: Bearer <token> header.
muzak.RequireBearerToken(settings.AdminToken)

// Any named header, such as the X-Token of the FastAPI tutorial or a webhook
// signing key.
muzak.RequireHeaderToken("X-Token", settings.AdminToken)
app.Include(routers.Admin(),
    muzak.WithPrefix("/admin"),
    muzak.WithTags("admin"),
    muzak.WithDependencies(core.GetTokenHeader(settings)),
)

RequireBearerToken answers a missing credential with a WWW-Authenticate header so a client knows which scheme to use. Both compare in constant time.

Note where the guard is attached. The admin router itself declares no prefix and no authentication; both are applied where it is included, which keeps the router reusable and puts the security decision somewhere a reviewer will find it.

Comparing secrets

if !muzak.SecureCompare(given, expected) {
    return muzak.Unauthorized("unauthorized")
}

Comparing a credential with == leaks its contents: the comparison returns as soon as two bytes differ, so an attacker who can time the response can recover the secret one byte at a time. SecureCompare hashes both inputs first and compares the digests, which also keeps the length of the expected secret from leaking.

Use it for tokens, API keys and signatures. It is not a password verification function. A password must be checked against a slow, memory-hard hash such as the one golang.org/x/crypto/argon2 provides.

Resolving a user

A per-user credential wants a provider, so the identity reaches the handler as a value.

// CurrentUser is the authenticated caller.
//
// It is produced once per request by GetCurrentUser and read inside a handler
// with muzak.From[core.CurrentUser](ctx), where the type is checked by the
// compiler and no cast is written anywhere.
type CurrentUser struct {
    Username string
}

// GetCurrentUser is a value dependency that resolves the caller from the
// Authorization header.
func GetCurrentUser(ctx *muzak.Context) (CurrentUser, error) {
    token, present := muzak.BearerToken(ctx)
    if !present {
        return CurrentUser{}, muzak.Unauthorized("unauthorized")
    }

    users := muzak.From[*core.UserStore](ctx)
    user, err := users.ByToken(ctx.Context(), token)
    if err != nil {
        // The same answer whether the token was unknown or the lookup failed,
        // so a caller cannot tell one from the other.
        return CurrentUser{}, muzak.Unauthorized("unauthorized")
    }
    return CurrentUser{Username: user.Username}, nil
}

muzak.Needs on a router applies to every route beneath it, which is how a whole authenticated subtree is declared once.

muzak.BearerToken(ctx) reads the Authorization header and reports whether a well-formed bearer credential was present. The scheme is matched case-insensitively, as RFC 9110 requires.

Sessions

A browser flow exchanges credentials for a session cookie once, then presents the cookie.

func Login(ctx *muzak.Context, in schemas.LoginIn) (schemas.LoginOut, error) {
    expected, known := accounts[in.Username]

    // The comparison runs even for an unknown account, and the same answer is
    // given either way. Returning "no such user" would turn this endpoint into
    // a way to enumerate accounts, and returning early would let its timing do
    // the same thing more quietly.
    matches := subtle.ConstantTimeCompare([]byte(expected), []byte(in.Password)) == 1
    if !known || !matches {
        return schemas.LoginOut{}, muzak.Unauthorized("the username or password is incorrect")
    }

    session := uuid.NewV4().String()
    ctx.SetCookie(&http.Cookie{
        Name:     "session_id",
        Value:    session,
        Path:     "/",
        HttpOnly: true,
        SameSite: http.SameSiteLaxMode,
        Secure:   true,
        MaxAge:   3600,
    })

    return schemas.LoginOut{Username: in.Username, SessionID: session}, nil
}

Four things that matter on a sign-in route, all visible above:

  • The same answer either way. Telling an unknown account from a wrong password turns the endpoint into an account enumerator.
  • The same timing either way. Returning early on an unknown account says the same thing more quietly, which is why the comparison runs regardless.
  • A rate limit counted before the guards. A request a guard rejects is still counted, so failed sign-ins cost the attacker their budget. AfterDependencies would give that up, which is why a login route never uses it.
  • A password checked against a slow hash. The example compares plain values because there is nothing to protect in it. A real one stores an argon2 or bcrypt digest.

Resolve the session on the way back in with a provider:

func GetSession(ctx *muzak.Context) (Session, error) {
    cookie, err := ctx.Cookie("session_id")
    if err != nil || cookie.Value == "" {
        return Session{}, muzak.Unauthorized("sign in first")
    }
    sessions := muzak.From[*core.SessionStore](ctx)
    return sessions.Lookup(ctx.Context(), cookie.Value)
}

Credentials on a WebSocket

A browser cannot set headers on a WebSocket handshake, so the two places a credential can arrive are a cookie the browser attaches itself and a query parameter the page puts in the URL.

// SessionOrToken is the caller of a WebSocket route, resolved from either a
// session cookie or a query parameter.
type SessionOrToken struct {
    Value      string
    FromCookie bool
}

// GetSessionOrToken resolves the caller of a WebSocket route.
//
// It runs during the handshake, before a single byte is upgraded, so a caller
// with no credential receives an ordinary JSON error rather than a connection
// that closes a moment later.
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")
}

A cookie is ambient authority a browser attaches without being asked, which is exactly why a cross-origin handshake is refused by default and why InsecureSkipOriginCheck is only safe for a connection authenticated by an explicit token. See WebSockets.

A token in a query string ends up in access logs and browser history. Muzak's own access log records no query strings for that reason; whatever is in front of the service may not be as careful.

Verifying a signed token

A JWT or a signed session is verified in a provider like anything else. Nothing about it is special to the framework.

func GetClaims(ctx *muzak.Context) (Claims, error) {
    token, present := muzak.BearerToken(ctx)
    if !present {
        return Claims{}, muzak.Unauthorized("unauthorized")
    }

    keys := muzak.From[*core.KeySet](ctx)
    claims, err := keys.Verify(token)
    if err != nil {
        // The reason stays server-side. Telling a caller which check failed
        // tells an attacker which one to work on next.
        return Claims{}, muzak.Unauthorized("unauthorized").Wrap(err)
    }
    if claims.ExpiresAt.Before(time.Now()) {
        return Claims{}, muzak.Unauthorized("the token has expired")
    }
    return claims, nil
}

Wrap records the real cause in the log and keeps it out of the response. See Error Handling.

The key set is a resource, so build it once and publish it with WithSingleton. If it refreshes from a remote endpoint, implement muzak.Lifecycle and let the application start and stop it. See Lifecycle.

Documenting an authenticated route

The credential itself is not part of the generated document, so document the outcome:

r.Get("/users/me", handlers.CurrentUser,
    muzak.Summary("Read the authenticated user"),
    muzak.Needs(core.GetCurrentUser),
    muzak.WithResponseDoc(http.StatusUnauthorized, "No usable credential was presented"))

Applied to a router or at an include, it documents the response for every route beneath.

Testing

func TestReadItemBadToken(t *testing.T) {
    client := testclient.New(t, buildApp())

    res := client.Get("/items/foo", testclient.Header("X-Token", "hailhydra"))

    res.AssertStatus(http.StatusUnauthorized)
    res.AssertErrorCode(muzak.CodeUnauthorized)
}
// authorized is the header every request in this suite needs.
func authorized() testclient.Option {
    return testclient.WithHeader("X-Token", "coneofsilence")
}

client := testclient.New(t, buildApp(), authorized())

Where to go next

Authorization covers what a resolved caller is allowed to do, and Cookies covers the attributes that decide whether a session is safe.

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