login

The login.example microservice demonstrates the framework’s actor-based authentication and authorization model end-to-end: a form-based login that mints a JWT, a cookie-based session that the ingress middleware exchanges for an access token, and per-endpoint authorization driven by requiredClaims expressions.

It hard-codes three users with different role sets — admin, manager, and user (all with password password) — and exposes endpoints that gate on those roles.

Depends On

  • bearertoken.core — mints the long-lived signed JWT that the Login handler places in the auth cookie.
  • accesstoken.core — the ingress middleware exchanges the bearer token for a short-lived access token, whose claims are propagated as the actor on every downstream call.
  • httpingress.core — the ingress proxy is configured in main to redirect 401s under this microservice’s path to the login page.

Try It

With the examples app running, visit the welcome page without logging in:

http://localhost:8080/login.example/welcome

The ingress middleware sees a 401 from the endpoint (no valid actor) and redirects you to:

http://localhost:8080/login.example/login

Log in as one of:

UsernamePasswordRoles claim
admin@example.compassword["a"]
manager@example.compassword["m","u"]
user@example.compassword["u"]

After login you are redirected to the welcome page. The page’s rendering varies by role. Try the role-gated endpoints to see authorization at work:

Log out at http://localhost:8080/login.example/logout.

Main App Wiring

Outside this microservice’s own package, the ingress proxy is set up in main to redirect 401s under /login.example/ to the login page. Without this, an unauthenticated request to /welcome would just return a 401 body — the redirect is what makes it feel like a normal web login.

httpingress.NewService().Init(func(svc *httpingress.Service) {
    svc.Middleware().Append("LoginExample401Redirect",
        middleware.OnRoute(
            func(path string) bool {
                return strings.HasPrefix(path, "/"+login.Hostname+"/")
            },
            middleware.ErrorPageRedirect(http.StatusUnauthorized, "/"+login.Hostname+"/login"),
        ),
    )
}),

middleware.OnRoute scopes the rule to paths under /login.example/; middleware.ErrorPageRedirect rewrites a 401 response into a redirect to /login.example/login.

Code Walkthrough

Source: exampleservices/login/service.go, exampleservices/login/actor.go.

The Service struct is bare:

type Service struct {
    *Intermediate // IMPORTANT: Do not remove
}

const (
    authTokenCookieName = "Authorization"
)

OnStartup and OnShutdown are empty. All session state lives in the JWT in the cookie; the service holds none locally.

Wiring (intermediate.go)

The five subscriptions from intermediate.go; the interesting part is the sub.RequiredClaims(...) option on the gated endpoints:

svc.Subscribe("Login",  svc.Login,  sub.At(...), sub.Web())
svc.Subscribe("Logout", svc.Logout, sub.At(...), sub.Web())
svc.Subscribe("Welcome", svc.Welcome, sub.At(...), sub.Web(),
    sub.RequiredClaims(`roles.a || roles.m || roles.u`))
svc.Subscribe("AdminOnly", svc.AdminOnly, sub.At(...), sub.Web(),
    sub.RequiredClaims(`roles.a`))
svc.Subscribe("ManagerOnly", svc.ManagerOnly, sub.At(...), sub.Web(),
    sub.RequiredClaims(`roles.m`))

sub.RequiredClaims(...) is a boolean expression over the actor’s claims. The framework rejects the request with 401 Unauthorized if no actor is present, or 403 Forbidden if an actor is present but does not satisfy the expression — before the handler runs. The handlers don’t need to check authorization themselves.

Login and Logout have no RequiredClaims because they must be reachable by unauthenticated users.

Actor (actor.go)

The application’s view of the authenticated user lives in actor.go. It is the struct that gets populated from JWT claims on every downstream request:

type Actor struct {
    Subject string   `json:"sub"`
    Roles   []string `json:"roles"`
}

func (a Actor) HasRole(role string) bool {
    return slices.Contains(a.Roles, role)
}

The convenience predicates IsAdmin(), IsManager(), IsUser() correspond to the a, m, u role codes. The framework cares only about the sub and roles claim names; the role codes are an application convention.

Login

func (svc *Service) Login(w http.ResponseWriter, r *http.Request) (err error) {
    ctx := r.Context()

    err = r.ParseForm()
    if err != nil {
        return errors.Trace(err)
    }
    u := r.FormValue("u")
    p := r.FormValue("p")
    src := r.FormValue("src")
    submitted := r.FormValue("l") != ""

    // Validate credentials (hardcoded)
    userRoles := map[string][]string{
        "admin@example.com":   {"a"},      // An admin is not a standard user
        "manager@example.com": {"m", "u"}, // A manager is a standard user too
        "user@example.com":    {"u"},      // An unprivileged standard user
    }
    ok := submitted && userRoles[u] != nil && p == "password"
    if ok {
        // Use the core issuer to create a JWT
        signedJWT, err := bearertokenapi.NewClient(svc).Mint(ctx, map[string]any{
            "sub":   u,
            "roles": userRoles[u],
        })
        // ... set as cookie, redirect ...
    }
    // ... else render the login form ...
}

Two pieces matter:

  • JWT minting goes through bearertokenapi.NewClient(svc).Mint(...) — not a local golang-jwt Sign call. Centralizing minting in bearertoken.core means rotation, signing-key management, and claim defaults are handled in one place. The handler hands over the desired claim map (sub, roles) and gets back a signed string.
  • The signed JWT is placed in an Authorization cookie with HttpOnly and Secure flags appropriate to the connection. The MaxAge is derived from the JWT’s exp claim so the browser drops the cookie at the same time the token becomes invalid. After login, the user is redirected to either the page that bounced them (the src form field) or the welcome page.

Logout is the inverse: the same cookie name with MaxAge: -1 to expire it, followed by a redirect back to the login page.

Welcome

func (svc *Service) Welcome(w http.ResponseWriter, r *http.Request) (err error) {
    var actor Actor
    _, err = frame.Of(r).ParseActor(&actor)
    if err != nil {
        return errors.Trace(err)
    }
    data := struct {
        Actor Actor
        Raw   string
    }{
        Actor: actor,
        Raw:   r.Header.Get(frame.HeaderActor),
    }
    // ... render welcome.html with `data` ...
}

frame.Of(r).ParseActor(&actor) reads the actor JWT from the framework’s Microbus-Actor header and unmarshals its claim payload into the Actor struct. The handler can trust the values it gets back because the ingress middleware already validated the bearer token and exchanged it for the short-lived access token whose claims now travel as the actor on every downstream call.

By the time control reaches Welcome, sub.RequiredClaims("roles.a || roles.m || roles.u") has already guaranteed at least one of those roles is present — the handler only has to render the role-aware view, not enforce access.

AdminOnly and ManagerOnly

func (svc *Service) AdminOnly(w http.ResponseWriter, r *http.Request) (err error) {
    w.Header().Set("Content-Type", "text/html")
    w.Header().Set("Content-Language", "en-US")
    err = svc.WriteResTemplate(w, "admin-only.html", nil)
    if err != nil {
        return errors.Trace(err)
    }
    return nil
}

The handlers themselves are nearly empty — they exist to demonstrate that authorization is enforced before the handler runs, by the sub.RequiredClaims subscription option (declared in Wiring above). A user without the roles.a claim never reaches AdminOnly’s body; they get a 403 directly.

This is the right place to enforce — moving the check into handler bodies would mean every endpoint needed to duplicate the same boilerplate, and a forgotten check would silently become a privilege escalation.

See Also

  • Actor JWT — the actor model, claim propagation, and requiredClaims expressions.
  • Bearer token core service — the JWT minting service the Login handler delegates to.
  • HTTP ingress middleware — the middleware pipeline that includes the 401-redirect rule, the bearer-to-access exchange, and the actor injection.
  • Bank support example — applies this same auth model in a fuller scenario, using the verified actor claim to scope an LLM agent’s tools to the signed-in customer’s own data.