auth
import infraauth "github.com/infrapi/lib/pkg/auth"
Authenticates requests against an upstream authentication service: it forwards the
caller’s bearer token to that service’s POST /oauth2/userinfo endpoint - the same
check it runs on every request it protects itself - and turns the result into a
gin middleware.
Creating a client#
client, err := infraauth.NewClient(infraauth.ClientOptions{
BaseURL: "https://auth.example.com",
})
BaseURL is mandatory. Timeout (default infraauth.DefaultTimeout, 5s) bounds each
call; HTTPClient overrides it entirely, for TLS, proxy or transport settings Timeout
alone cannot express.
Verifying a token directly#
user, err := client.Verify(ctx, token)
if err != nil {
return err // *errors.Error: 401 from the auth service, or an upstream failure
}
fmt.Println(user.Subject, user.Provider, user.Groups)
type UserInfo struct {
Subject string // "sub": the user id the service resolved the token to
Provider string // the identity provider that verified the token's signature
Authorization string // the backend that resolved Subject's groups and attributes
Groups []string
Attributes map[string]string // directory fields: email, name, employee_id, ...
}
A token the service itself refuses - missing, malformed, expired, signed by an unknown
provider - comes back as a 401 *errors.Error. The service answering
with anything else comes back as errors.Upstream, and not answering at all as
errors.Unavailable, so a caller can tell “this caller is not who they claim” from
“the auth service is having a bad day”.
Gating gin routes#
Client.Middleware() is a gin.HandlerFunc that runs Verify against the request’s
Authorization header and stores the result for downstream handlers:
engine.GET("/widgets", client.Middleware(), func(c *gin.Context) {
user, _ := infraauth.GetUser(c)
c.JSON(http.StatusOK, gin.H{"owner": user.Subject})
})
A missing or empty bearer header, or a token the service refuses, answers with a
problem-detail response via server.AbortError and
stops the chain - no handler downstream runs.
Caching Verify results#
A successful Verify is kept in memory for ClientOptions.CacheTTL, keyed by a SHA-256
hash of the token - never the token itself - so a request reusing the same bearer token
within the window skips the HTTP round trip entirely. Middleware benefits from this
automatically, since it calls Verify.
client, err := infraauth.NewClient(infraauth.ClientOptions{
BaseURL: "https://auth.example.com",
})
Caching is on by default: leaving CacheTTL unset (0) caches for DefaultCacheTTL
(1h). Set it to another time.Duration to tune that, or to a negative value to turn
caching off entirely - every call then reaches the authentication service, so a revoked
token or a changed group membership takes effect immediately.
infraauth.ClientOptions{BaseURL: "...", CacheTTL: 10 * time.Minute} // tune it
infraauth.ClientOptions{BaseURL: "...", CacheTTL: -1} // disable it
- Only success is cached: a token the service refuses is checked again on every call, so
a typo or an expired token is never one you have to wait
CacheTTLout. - Each
Verifyreturns its own copy of the cachedUserInfo- mutatingGroupsorAttributesin a handler never corrupts what the next cache hit serves. CacheTTLtrades freshness for load: a revoked token, or a user removed from a group, stays valid in this process for up toCacheTTLafter the change. Pick a value your service’s security posture tolerates, or disable caching if it tolerates none.
Gating every route at once#
Pass it to server.WithAuth instead of wiring it onto each route:
engine, err := server.NewServerGin(cfg, server.WithAuth(client.Middleware()))
Every route the service adds after this call requires a token the auth service accepts;
the engine’s own endpoints under /-/ - registered before it, same as WithOpenAPI’s
contract validation - are not gated.
examples/auth runs all of this against a stub authentication server.
Authorizing api/resource/verb access#
A service mounting its routes with server.APIGroup, under
api’s layout
/apis/{name}.{domain}/{version}/namespaces/{namespace}/{resource} -
Kayra’s convention for RBAC-protected APIs - gates
them on Client.RequireAccess() instead of hand-rolling its own RBAC storage and
matching:
vms, err := server.APIGroup(engine, api.Group{Name: "compute", Domain: "giservices.io", Version: "v1"})
vms.Use(client.RequireAccess())
vms.GET("/vm/:id", getVM)
vms.POST("/vm/:id/reboot", rebootVM)
RequireAccess takes the request path apart with api.Parse - the API URN
(compute.giservices.io) and the namespace come from it, Resource/Verb from the rest
of the path and the HTTP method (see below) - and calls the authentication/authorization
service’s POST AuthorizePath (/apis/authorization.giservices.io/v1/check) with the
caller’s own bearer token to get an allow/deny decision; the actual RBAC rules never leave
that service, this package only shapes the request. It reads the request path rather than
route parameters, so any route pattern works, a *resource wildcard as well as named
:id segments; a path outside the layout answers 404.
A denial answers 403 with the service’s own reason in detail; it does not depend on
Middleware having run first (each guards a different concern — identity vs. access —
and reaches the service independently), and it is never cached, so an edited role or
rolebinding takes effect on the very next request.
p, err := api.Parse(c.Request.URL.Path)
resource, hasID, err := infraauth.ParseResource(p.Resource)
verb, err := infraauth.VerbFor(c.Request.Method, hasID)
These are what RequireAccess calls, exported in case a caller needs the
same parsing without the HTTP round trip. The path after
/namespaces/{namespace} alternates resource/action names and instance ids
— /vm → resource "vm", no id; /vm/123 → resource "vm", id
"123"; /vm/123/reboot → resource "vm/reboot" (a sub-resource
action’s name is just part of Resource), id "123". VerbFor maps
the HTTP method to the verb an RBAC rule grants: GET splits into
"list" (no id) or "get" (has one); every other method maps 1:1
regardless of id (POST→"create", PUT→"update",
PATCH→"patch", DELETE→"delete") — an action call like
PATCH .../vm/123/reboot is just as unambiguous as PATCH .../vm/123.
allowed, reason, err := client.Authorize(ctx, token, infraauth.AuthorizeRequest{
Namespace: "core", Api: "compute.giservices.io", Resource: "vm", Verb: "list",
})
The lower-level call RequireAccess wraps, for a caller that already has
Namespace/Api/Resource/Verb in hand and wants the raw decision
instead of a gin middleware.