auth

Using auth standalone

A complete, runnable example with no gp-system dependency beyond auth itself: HTTP and non-HTTP.

Everything on the previous three pages works without the gpsystem kit, without server.WriteError, and without any other gp-system/* module. This page is the concrete version of that claim: a project whose go.mod requires nothing but github.com/gp-system/auth, wiring its own net/http middleware, its own role checks, its own policy registry, and its own error rendering by hand.

go mod init example.com/standalone-demo
go get github.com/gp-system/auth
require github.com/gp-system/auth v0.1.0

No errs, no httperr, no dbx, nothing else: auth's own go.mod requires only golang-jwt/jwt/v5, and that is the only thing that ends up in yours too.

Issuing and checking tokens

package main

import (
    "time"

    "github.com/gp-system/auth"
)

var issuer = auth.NewTokenIssuer[auth.DefaultClaims](auth.Config{
    Secret:    "dev-secret-change-me", // in real code: from env/secret manager
    AccessTTL: 15 * time.Minute,
    Issuer:    "standalone-demo",
})

func issueToken(userID, username string, roles, permissions []string) (string, error) {
    claims := auth.NewDefaultClaims(auth.Config{Issuer: "standalone-demo", AccessTTL: 15 * time.Minute},
        userID, username, roles, permissions)
    return issuer.Sign(claims)
}

A hand-written net/http middleware

Identify does the parsing and identity mapping; you decide what happens on failure. Here we write a small JSON body ourselves, with errors.Is picking the status code, no httperr involved:

package main

import (
    "encoding/json"
    "errors"
    "net/http"

    "github.com/gp-system/auth"
    "github.com/gp-system/auth/rbac"
)

func requireAuth(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        id, err := auth.Identify(issuer, auth.DefaultIdentity, r.Header.Get("Authorization"))
        if err != nil {
            writeAuthError(w, err)
            return
        }
        next.ServeHTTP(w, r.WithContext(rbac.WithIdentity(r.Context(), id)))
    })
}

func writeAuthError(w http.ResponseWriter, err error) {
    status := http.StatusInternalServerError
    switch {
    case errors.Is(err, auth.ErrMissingToken),
        errors.Is(err, auth.ErrInvalidToken),
        errors.Is(err, auth.ErrInvalidClaims):
        status = http.StatusUnauthorized
    }
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    _ = json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
}

This is not the only shape: a middleware could just as well render text/plain with http.Error, or reuse an error-handling convention your codebase already has. auth has no opinion; it only guarantees errors.Is works against the three sentinels.

Role checks without a middleware

rbac.CheckRole/CheckPermission are plain functions; you can call them directly in a handler, no guard middleware required:

func adminOnly(next http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        id := rbac.FromContext(r.Context())
        if err := rbac.CheckRole(id, "admin"); err != nil {
            status := http.StatusForbidden
            if errors.Is(err, rbac.ErrUnauthenticated) {
                status = http.StatusUnauthorized
            }
            http.Error(w, err.Error(), status)
            return
        }
        next(w, r)
    }
}

Wire it after requireAuth:

mux := http.NewServeMux()
mux.Handle("/admin/reports", requireAuth(adminOnly(handleReports)))
http.ListenAndServe(":8080", mux)

A policy registry, wired by hand

policy.Enforcer targets oapi-codegen's strict-server shape, but nothing stops you from calling a Registry directly for a hand-rolled endpoint:

package main

import (
    "context"
    "errors"
    "net/http"

    "github.com/gp-system/auth/policy"
    "github.com/gp-system/auth/rbac"
)

var orderPolicies = func() *policy.Registry {
    reg := policy.NewRegistry()
    reg.Register("orders.view", func(_ context.Context, id *rbac.Identity, req any) error {
        orderOwnerID := req.(string) // in real code: looked up from the order record
        if id.HasRole("admin") || orderOwnerID == id.Subject {
            return nil
        }
        return policy.Deny("you can only view your own orders")
    })
    return reg
}()

func handleGetOrder(w http.ResponseWriter, r *http.Request) {
    id := rbac.FromContext(r.Context())
    fn, _ := orderPolicies.Get("orders.view")
    if err := fn(r.Context(), id, lookupOrderOwner(r)); err != nil {
        var d *policy.Denial
        if errors.As(err, &d) {
            http.Error(w, d.Detail, http.StatusForbidden)
            return
        }
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }
    // ... write the order
}

No HTTP at all: a WebSocket handshake

Identify takes a header value, not a *http.Request, so the same call works wherever a bearer token shows up: a WebSocket handshake's Sec-WebSocket-Protocol trick, a first message on the socket, gRPC metadata, anything.

func acceptConnection(authorizationValue string) (*rbac.Identity, error) {
    id, err := auth.Identify(issuer, auth.DefaultIdentity, authorizationValue)
    if err != nil {
        return nil, err // caller decides how to close the connection / send a control frame
    }
    if err := rbac.CheckPermission(id, "realtime.connect"); err != nil {
        return nil, err
    }
    return id, nil
}

There is no response to write here, so there is nothing to adapt: the same errors.Is/errors.As vocabulary from the HTTP examples above applies unchanged.

What the kit adds on top

The gpsystem kit's server package (server.AuthMiddleware, server.RequireRole/RequirePermission, server.WriteError) is exactly the requireAuth/adminOnly/error-rendering code above, written once and shared across every generated project, rendering as RFC 9457 problem+json instead of the ad hoc JSON/plain-text shown here. See Authentication for that wiring. Nothing about auth, rbac or policy changes when you add the kit: the same sentinels and the same *policy.Denial are what the kit's glue matches on.

Copyright © 2026