Authentication
Import auth to issue JWTs and turn a bearer token into an identity:
import "github.com/gp-system/auth"
auth is a standalone, stateless JWT module with no dependency on any other gp-system package, not even errs or httperr: its only dependency is golang-jwt/jwt/v5. It provides two things: a token issuer (JWT signing and verification) and Identify, a function that turns a bearer token into an rbac.Identity. From there on, every authorization decision (the role and permission checks, the policies) happens over the Identity, independent of the token.
auth never writes an HTTP response, and Identify doesn't even take a *http.Request: it takes a header value. That makes it usable from any transport, not just net/http: a WebSocket handshake, a gRPC interceptor reading metadata, a message-queue consumer checking a token in a message header. Every failure is a plain Go error (a sentinel or a small typed value); the caller decides how to render it. See Using auth standalone for worked examples with and without net/http. The gpsystem kit adds a net/http middleware and RFC 9457 Problem rendering on top, in its server package.Authentication here is built around a stateless JWT: the login endpoint hands out a signed token, every subsequent request carries it, and the server keeps no session store: the token itself carries the subject, roles and permissions.
jwt.Claims interface, and a custom claims struct embeds jwt.RegisteredClaims. The module centralizes here rather than abstracting: your JWT-ecosystem knowledge transfers one to one.Installation
go get github.com/gp-system/auth@v0.1.0 # the kit uses this same tag
# or: go get github.com/gp-system/auth@latest
auth's only dependency is golang-jwt/jwt/v5; the auth/rbac and auth/policy subpackages ship inside the same module, no extra go get. Nothing here requires the gpsystem kit or any other gp-system module: see Using auth standalone for a project that imports only github.com/gp-system/auth.
Configuration
auth.Config loads from the environment; in a generated project's config you add it under the // gpsystem:config anchor with the JWT_ prefix:
// internal/platform/config/config.go
type Config struct {
Server server.Config
DB dbx.Config `envPrefix:"DB_"`
JWT auth.Config `envPrefix:"JWT_"`
// gpsystem:config
}
| Env variable | Field | Default |
|---|---|---|
JWT_SECRET | Secret | required |
JWT_ACCESS_TOKEN_TTL | AccessTTL | 15m |
JWT_REFRESH_TOKEN_TTL | RefreshTTL | 168h (7 days) |
JWT_ISSUER | Issuer | empty (issuer check disabled) |
JWT_AUDIENCE | Audience | empty (audience check disabled) |
Issuer and Audience, and use a distinct Secret per service and per environment: that way a token minted for another service or for staging cannot be replayed against the production API. When Issuer/Audience are set, parsing requires a matching iss/aud claim. Full env reference: Configuration.Issuing tokens: the login flow
The kit's standard claims shape is DefaultClaims: compatible with the rbac.Identity fields, embedding jwt.RegisteredClaims:
type DefaultClaims struct {
Username string `json:"username,omitempty"`
Roles []string `json:"roles,omitempty"`
Permissions []string `json:"permissions,omitempty"`
jwt.RegisteredClaims
}
NewDefaultClaims stamps the registered claims (iat, exp, iss, sub, plus aud when configured) from the config; the TokenIssuer signs and parses the token. This is what the shop's login service looks like, sketched:
// internal/modules/shop/surfaces/admin/service/auth.go (sketch)
type Service struct {
jwt auth.Config
issuer *auth.TokenIssuer[auth.DefaultClaims]
users UserRepo
tokens RefreshTokenRepo
}
func New(jwtCfg auth.Config, users UserRepo, tokens RefreshTokenRepo) *Service {
return &Service{
jwt: jwtCfg,
issuer: auth.NewTokenIssuer[auth.DefaultClaims](jwtCfg),
users: users,
tokens: tokens,
}
}
func (s *Service) Login(ctx context.Context, email, password string) (LoginResult, error) {
user, err := s.users.FindByEmail(ctx, email)
if err != nil || !user.CheckPassword(password) {
return LoginResult{}, ErrInvalidCredentials
}
claims := auth.NewDefaultClaims(s.jwt, user.ID, user.Name,
user.Roles, user.Permissions) // iat/exp/iss/sub stamped from config
access, err := s.issuer.Sign(claims)
if err != nil {
return LoginResult{}, err
}
refresh, err := auth.NewRefreshToken() // 32 bytes crypto/rand, hex
if err != nil {
return LoginResult{}, err
}
expiresAt := time.Now().Add(s.jwt.RefreshTTL)
if err := s.tokens.Store(ctx, user.ID, sha256Hex(refresh), expiresAt); err != nil {
return LoginResult{}, err
}
return LoginResult{AccessToken: access, RefreshToken: refresh}, nil
}
NewRefreshToken only returns a cryptographically random string, the kit does not store it. Server-side, store only its hash (as with passwords), and use RefreshTTL as the expiry (the sha256Hex above is your own helper).Identify: turning a token into an identity
Identify does the work a middleware usually hides: it extracts the bearer token from an Authorization header value, validates it with the TokenIssuer (signature, expiry, issuer/audience), and maps the resulting claims onto an *rbac.Identity. It takes a header value, not a request or a response writer, and returns a plain error on failure:
id, err := auth.Identify(issuer, auth.DefaultIdentity, authorizationHeader)
switch {
case err == nil:
// id is populated; proceed
case errors.Is(err, auth.ErrMissingToken):
// no bearer token at all
case errors.Is(err, auth.ErrInvalidToken):
// bad signature, expired, wrong issuer/audience
case errors.Is(err, auth.ErrInvalidClaims):
// token valid, but toIdentity rejected the claims
}
auth.DefaultIdentity is the toIdentity function for DefaultClaims; a custom claims type provides its own (see Custom claims below).
With the kit: server.AuthMiddleware
The gpsystem kit wraps Identify in a net/http middleware and renders its errors as RFC 9457 problem+json responses. This is how the shop's admin surface goes behind JWT + role protection: you add the middleware inside the generated Register<Surface> function, without touching the other surfaces (see add surface):
func RegisterAdmin(router chi.Router, deps Dependencies) {
adminSvc := adminservice.New()
adminHandler := adminhttp.New(adminSvc)
adminPolicies := adminpolicy.New()
router.Route("/admin/shop", func(r chi.Router) {
r.Use(server.AuthMiddleware(deps.Auth), server.RequireRole("admin"))
shopadmingen.HandlerWithOptions(/* ... generated strict-handler wiring unchanged ... */)
})
}
You pass auth.Config in through the module's Dependencies struct (an Auth auth.Config field at the // gpsystem:dependencies anchor, Auth: cfg.JWT in main.go). This way register.go never depends on the platform config. server.AuthMiddleware/server.RequireRole/server.RequirePermission live in the kit, not in auth itself: see the kit server for the full server.WriteError mapping.
Mixed surfaces: public and protected operations side by side
server.AuthMiddleware rejects every tokenless request with 401; for an all-protected surface (like admin) that is exactly what you want. The shop's api surface is mixed, though: product browsing is anonymous, while the order operations require an identity. In that case, make token parsing conditional, and leave the 401 for tagged operations to the policy enforcer, which rejects without an identity anyway:
authOptional := server.AuthMiddleware(deps.Auth)
r.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") == "" {
next.ServeHTTP(w, r) // anonymous browsing passes; tagged operations get 401 from the enforcer
return
}
authOptional(next).ServeHTTP(w, r) // if a token is present, it must be valid
})
})
Custom claims
When DefaultClaims is not enough (say you carry a tenant identifier in the token), define your own claims type and write your own toIdentity function for Identify (or server.AuthMiddlewareFor, its kit-wired net/http counterpart):
type MyClaims struct {
TenantID string `json:"tenant"`
jwt.RegisteredClaims
}
toIdentity := func(c MyClaims) (*rbac.Identity, error) {
return &rbac.Identity{
Subject: c.Subject,
Extra: map[string]any{"tenant": c.TenantID},
}, nil
}
issuer := auth.NewTokenIssuer[MyClaims](cfg.JWT)
mw := server.AuthMiddlewareFor(issuer, toIdentity) // kit net/http middleware
id, err := auth.Identify(issuer, toIdentity, authorizationHeader) // or call it directly
server.AuthMiddleware is exactly server.AuthMiddlewareFor pre-wired with DefaultClaims and auth.DefaultIdentity, so both share the same func(http.Handler) http.Handler shape and the same Identity-construction contract; reach for AuthMiddlewareFor only when DefaultClaims doesn't carry what you need.
HS256 and secret management
Signing is HS256 (shared secret). Parsing pins the algorithm (WithValidMethods), requires an expiry (WithExpirationRequired), and when configured, checks issuer and audience, so alg-confusion attacks and unsigned tokens are rejected. The TokenIssuer API is claims-generic, so asymmetric algorithms can be added later without breaking consumers.
The secret should be long and random (e.g. openssl rand -hex 32) and come from a secret manager: never commit it to the repo. .env is for local development only; see the configuration page.
Standalone, no kit
Nothing above needs server.Run or any other kit package. Identify and the rbac/policy sentinel errors are all you need to wire a bare net/http service, or a non-HTTP one, with your own error rendering: see Using auth standalone for full, runnable examples.
The next layer: role and permission checks over the Identity the token became, and per-request policies. For 3rd-party (Discord/Facebook/Apple/Google) login, the module add auth generates has its own social login layer calling into this same token issuing.
Patterns used
Generics over the claims type (TokenIssuer[C jwt.Claims], Identify[C]): Design patterns.