authn

package
v1.0.5 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 29, 2026 License: Apache-2.0 Imports: 7 Imported by: 0

Documentation

Overview

Package authn provides the authentication interceptor mechanism that forge-generated services wire from their thin, user-owned pkg/middleware file.

Shape

The library owns the MECHANISM:

  • construction-time refusal: a production server with no auth provider configured must not start (see NewInterceptor);
  • mode resolution (validator installed / external provider), decided once at construction, never per-request;
  • the exact-match unauthenticated-procedure allow-list gate;
  • Bearer-token extraction and the CodeUnauthenticated error envelope (a missing credential is a 401, never a silent pass-through) — reading the Authorization header by default, or whatever channel Policy.ExtractToken names;
  • claims plumbing: validate → enrich → stash on the context;
  • the claims stash itself — Claims, ContextWithClaims, ClaimsFromContext and the GetUser handler helper, over a context key this package keeps private so there is exactly one key in play.

The project owns the POLICY, passed in as a Policy value from the scaffolded-once pkg/middleware/middleware.go:

  • the token validator (and when it gets installed),
  • the identity enricher hook (e.g. hydrate claims from the user table after signature validation),
  • where the credential is read from (Policy.ExtractToken — the Authorization header by default, a cookie or bespoke header when the project's session lives somewhere else), and
  • the allow-list contents.

A project may still stash claims under a context key it owns by setting Policy.ContextWithClaims; it then owns the matching reader, because this package's readers look under this package's key.

Modes

NewInterceptor resolves exactly one of three modes at construction time (decision order matters; first match wins):

  1. Validate — Policy.ValidatorConfigured is true. A present Bearer token is validated; a present-but-invalid token is rejected. Whether a MISSING token is rejected depends on Policy.AnonymousOK: when set (the forge scaffold default) authentication is a non-gating middleware — a token-less request proceeds claim-less and handlers enforce identity via authn.GetUser; when false every procedure not in Policy.Unauthenticated REQUIRES a valid Bearer token.
  2. Passthrough — Policy.ExternalAuth is true: something else in the chain owns identity. This is the ONLY way to run a forge service without this interceptor authenticating, and it is a field a human wrote into the project's own middleware file, visible in the project's source and in code review. There is deliberately no environment variable that turns authentication off: an ambient opt-out is settable from any shell, appears in no config the app can read, and makes "this server authenticates" unprovable from the source.
  3. Unconfigured — none of the above. NewInterceptor returns an error and startup must abort: a production server with no auth provider is always a bug, and refusing to start is safer than silently accepting (or silently rejecting) every request.

Usage from the project's middleware package

// pkg/middleware/middleware.go (user-owned, scaffolded once)
func NewAuthInterceptor(deps AuthDeps) (connect.Interceptor, error) {
    return authn.NewInterceptor(authn.Policy{
        ValidatorConfigured: deps.Validate != nil,
        ExternalAuth:        deps.ExternalAuth,
        AnonymousOK:         deps.AnonymousOK,
        ValidateContext:     deps.Validate, // (*auth.Authenticator).Authenticate
        Unauthenticated:     UnauthenticatedProcedures,
        Enrich:              enrichClaims,
        ExtractToken:        credentialFrom, // Bearer, else X-API-Key, else the session cookie
    })
}

Layering ADDITIONAL context (the Decorate seam)

Some projects need to install MORE than the library's single claims stash once a request is authenticated — a second, parallel identity context (e.g. a ported internal/auth user-id context), the raw Authorization header for outbound propagation, or any enrichment that writes context values rather than rewriting Claims. The library owns the hard mechanism (header extraction, validation, error mapping, the claims stash, the allow-list); the project layers its extra context through Policy.Decorate, which runs at the SINGLE post-authentication chokepoint in the Validate path. Without it a project that needs a dual-context bridge had to fork the whole interceptor for one missing callback — Decorate is that callback, so the fork collapses to a Policy value.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func ContextWithClaims

func ContextWithClaims(ctx context.Context, claims *Claims) context.Context

ContextWithClaims returns a new context carrying claims. This is the stash NewInterceptor uses in Validate mode when Policy.ContextWithClaims is nil.

func NewInterceptor

func NewInterceptor(p Policy) (connect.Interceptor, error)

NewInterceptor resolves the policy into a Connect interceptor, or refuses construction when no auth provider is configured and no explicit opt-out was given. Callers must treat the error as fatal and abort startup before binding the listener.

Types

type Claims

type Claims = auth.Claims

Claims is the canonical claims type carried through an authenticated request. It is an alias for auth.Claims so library code and project code name the same type — a project that wants extra identity fields hydrates them in its enricher hook rather than forking the type.

func ClaimsFromContext

func ClaimsFromContext(ctx context.Context) (*Claims, bool)

ClaimsFromContext retrieves the claims installed by ContextWithClaims. It returns nil, false when the request carried no principal — an unauthenticated caller on an allow-listed procedure, or any caller when Policy.AnonymousOK let a credential-less request through.

func GetUser

func GetUser(ctx context.Context) (*Claims, error)

GetUser resolves the signed-in caller, or returns CodeUnauthenticated when the context carries no principal.

This is the seam between authentication and access control. The interceptor decides whether a request may proceed AT ALL; deciding what this caller may do, and which rows they may see, is handler logic — call GetUser to resolve the principal and then apply your own checks and query filters. forge generates none of that, because no annotation can express it.

type Policy

type Policy struct {
	// ValidatorConfigured reports whether the project installed a real
	// credential validator. When true the interceptor runs in Validate
	// mode and exactly one of Validate and ValidateContext must be set.
	ValidatorConfigured bool

	// ExternalAuth reports whether an auth provider (a pack, or
	// hand-rolled setup code) registered its own interceptor alongside
	// this one. The interceptor then becomes a pure passthrough so the
	// external interceptor is the sole source of truth.
	//
	// This is also the ONLY disable seam: setting it is how a service
	// runs without this interceptor checking anything. It is a code-level
	// decision on purpose — the field is written in the project's own
	// middleware file, so "does this server authenticate?" is answered by
	// reading the source rather than by knowing what was exported in the
	// shell that started it.
	ExternalAuth bool

	// Validate validates a raw bearer token and returns the claims.
	// Called per-request in Validate mode, through whatever indirection
	// the project supplies — pass the project's ValidateToken wrapper
	// (not the validator function itself) so a validator installed or
	// swapped after interceptor construction still takes effect. Mode
	// resolution remains construction-time: if no validator is
	// installed by the time the interceptor is built, set
	// ValidatorConfigured accordingly and construction is refused.
	//
	// Set exactly one of Validate and ValidateContext.
	Validate func(token string) (*auth.Claims, error)

	// ValidateContext is Validate with the request's context. Use it for
	// a validator that does I/O — an API-key lookup in the application's
	// database, a token-introspection call — so the lookup is cancelled
	// with the request, carries its deadline, and opens its spans under
	// the request's trace. Validate has nowhere to put a context, which
	// left such a validator running on context.Background(), detached
	// from everything the request carries.
	//
	// [auth.Authenticator.Authenticate] has this signature: it routes a
	// bearer token to a JWT validator and an API key to a key validator
	// installed after the interceptor is built (late binding is the
	// Authenticator's job, so pass its method value directly).
	//
	// Set exactly one of Validate and ValidateContext: NewInterceptor
	// refuses a policy that sets both, because two validators for one
	// credential is a configuration error, not a precedence question.
	ValidateContext func(ctx context.Context, token string) (*auth.Claims, error)

	// Unauthenticated is the explicit allow-list of procedures that
	// bypass authentication. Entries must be FULL procedure strings of
	// the form "/package.Service/Method" — matching is exact, never by
	// substring, so a user-defined "HealthReport" RPC can't ride along
	// with "/grpc.health.v1.Health/Check".
	Unauthenticated map[string]struct{}

	// AnonymousOK makes authentication NON-GATING in Validate mode:
	// authentication becomes a middleware that validates a token IF ONE IS
	// PRESENT and otherwise lets the request proceed claim-less. A missing
	// Authorization header calls next() with no claims (a handler that needs
	// a principal enforces that itself via authn.GetUser); a PRESENT
	// token is still validated, and a present-but-INVALID token is still a
	// CodeUnauthenticated error (a bad credential is a real error, never a
	// silent anonymous pass). This is the default the forge scaffold sets —
	// access control is handler logic (GetUser), not a blanket
	// 401-by-annotation. Leave false to keep the strict "every non-allowlisted
	// procedure requires a valid token" posture. No effect outside Validate
	// mode (passthrough/dev are already non-gating).
	AnonymousOK bool

	// Enrich, when non-nil, runs after token validation and before the
	// claims are stashed on the context. Projects use it to hydrate
	// identity (roles from the DB, org membership, feature flags) onto
	// the validated claims. Returning an error rejects the request: a
	// *connect.Error is passed through verbatim, anything else becomes
	// CodeUnauthenticated.
	Enrich func(ctx context.Context, claims *auth.Claims) (*auth.Claims, error)

	// ContextWithClaims stashes validated claims on the context.
	//
	// Optional: when nil, Validate mode uses the library's own
	// [ContextWithClaims], whose principal is read back by
	// [ClaimsFromContext] and [GetUser]. Set it only to stash claims under
	// a context key the project owns instead — in which case the project
	// is also responsible for the reader, since the library's readers look
	// under the library's key.
	ContextWithClaims func(ctx context.Context, claims *auth.Claims) context.Context

	// Decorate, when non-nil, runs at the SINGLE post-authentication
	// chokepoint — AFTER the library has installed claims via
	// ContextWithClaims — in the Validate path (a real Bearer token was
	// validated and, if set, Enrich'd). It lets the project layer
	// ADDITIONAL context the library does not own:
	//
	//   - a second, parallel identity context (e.g. a ported
	//     internal/auth user-id context that other packages read);
	//   - the raw Authorization header, for forwarding the caller's
	//     identity on outbound calls — passed as authorization so the
	//     project never has to re-derive it;
	//   - feature flags, or any context-valued
	//     enrichment that writes onto ctx rather than rewriting Claims.
	//
	// Decorate only ADDS context; it cannot reject the request. Reject
	// at validation (Validate) or claims rewriting (Enrich), both of
	// which run before Decorate. nil (the default) leaves the context
	// exactly as the library produced it — behaviour is unchanged.
	//
	// Decorate does NOT run in passthrough mode (ExternalAuth): there are
	// no claims to decorate around, because another interceptor owns
	// identity entirely.
	Decorate func(ctx context.Context, claims *auth.Claims, authorization string) context.Context

	// ExtractToken, when non-nil, reads the caller's raw credential out of
	// the request headers, replacing the default "Bearer token in the
	// Authorization header" channel.
	//
	// WHY THIS EXISTS. The Authorization header is the right default, but it
	// is not the only place a credential legitimately lives. The case that
	// forced this seam: a browser console whose session is an HttpOnly
	// cookie. The token is deliberately unreadable to scripts (that is what
	// keeps an XSS from stealing it), so no client code CAN attach an
	// Authorization header — and every RPC failed with "missing
	// Authorization header" while the identical token authenticated fine as
	// a Bearer. The credential was valid and present; it just arrived in a
	// header this package did not read. Without a seam the only fixes were
	// to fork the interceptor or to have a proxy synthesize the header,
	// which puts credential handling in a transport layer that should not
	// have any.
	//
	// It returns the RAW token — no "Bearer " prefix, which the library does
	// not strip from an extractor's result. Returning "" means NO CREDENTIAL
	// WAS PRESENTED and is treated exactly like a missing Authorization
	// header: CodeUnauthenticated, or a claim-less pass when AnonymousOK is
	// set. An extractor cannot itself reject a request; a malformed
	// credential should come back as "" (missing) or as a token that fails
	// Validate.
	//
	// This changes only WHERE the credential is read from. Validation, the
	// claims stash, Enrich, Decorate, MapError and the allow-list gate are
	// unchanged, so a custom channel cannot weaken authentication — a token
	// found by an extractor goes through exactly the same validator as one
	// found in the Authorization header.
	//
	// nil (the default) reads "Authorization: Bearer <token>", so existing
	// services are unaffected.
	ExtractToken func(header http.Header) string

	// MapError, when non-nil, maps a token-validation failure into the
	// connect error returned to the caller. It receives the raw error
	// from Validate and the connect.Error the library would return by
	// default (always CodeUnauthenticated, wrapping the validation
	// error). Projects use it to distinguish, say, an expired token
	// (CodeUnauthenticated) from a revoked account (CodePermissionDenied)
	// without forking the interceptor. Returning nil falls back to the
	// library default. nil (the default) keeps the standard
	// CodeUnauthenticated envelope. Applies only to validator failures;
	// a missing or malformed Authorization header is always the
	// library's CodeUnauthenticated (those are protocol errors, not
	// policy decisions).
	MapError func(err error, fallback *connect.Error) *connect.Error
}

Policy carries the project-owned authentication decisions into the library mechanism. See the package doc for the policy/mechanism split. The zero value resolves to the Unconfigured mode (construction refused) outside dev mode — fail-closed by default.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL