Using auth standalone
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.