Documentation
¶
Overview ¶
Package auth authenticates requests: it resolves the user of a request and stores it in the context. Authorization lives in the gate subpackage, and authtest checks Authenticator implementations.
An Authenticator turns a request into a user of your own type U. It returns ErrNoCredentials when the request has nothing it understands, so Middleware tries the next one; any other error stops the request. Anonymous requests continue without a user; Require rejects them. Require uses Builder construction policies; session authentication uses immutable SessionIdentity to bind identifier type, loader and session.Handle. Zero definitions and nil loaders are invalid configuration; callbacks still run once in order. A later zero handle restores default lookup. Loaders are not invoked during construction. Invalid identities stop middleware fallback. Handles do not isolate cookies, storage or user contexts (keyed only by U). One physical session has one identity slot. Definitions with the same K can still mix realms, and compatible persisted JSON may decode across types.
Wiring ¶
Order matters: session, then authentication, then everything that needs the user (rate limits keyed by user, routes).
r := router.New()
identity := auth.NewSessionIdentity(loadUser)
if err := identity.Err(); err != nil {
return err
}
r.Use(session.Middleware(store))
r.Use(csrf.Middleware())
r.Use(auth.Middleware(identity.Authenticator(), auth.Bearer(verifyKey)))
r.Use(ratelimit.Middleware(store, func(limiter *ratelimit.Builder) {
limiter.KeyFunc(func(r *http.Request) string {
if user, err := request.User[User](r); err == nil {
return "user:" + strconv.Itoa(user.ID)
}
return "ip:" + r.RemoteAddr
})
}))
r.Post("/login", login)
r.Post("/logout", logout)
private := r.With(auth.Require[User]())
private.Get("/me", me)
r.With(gate.Require(isAdmin)).Delete("/users/{id}", deleteUser)
Where loadUser resolves the stored identifier:
func loadUser(ctx context.Context, id int) (User, error) {
user, err := db.Find[User](ctx, query.Table("users", func(statement *query.Builder) {
statement.Where("id", id)
}))
if errors.Is(err, database.ErrNoRows) {
return User{}, auth.ErrNoCredentials // deleted user: anonymous
}
return user, err
}
Login and logout ¶
Hash passwords with the hash package. Precompute dummyHash at startup with the same hasher and cost as real passwords. Checking it for unknown emails reduces timing differences; it does not guarantee identical request times. A missing account or password mismatch is a credential rejection; return database and hash failures without replacing them with ErrUnauthenticated. JSON v2 matches field names case-sensitively, so tag the lower-case request members:
func login(w http.ResponseWriter, r *http.Request) error {
in, err := request.JSON[struct {
Email string `json:"email"`
Password string `json:"password"`
}](r)
if err != nil {
return err
}
user, err := db.Find[User](r.Context(), query.Table("users", func(statement *query.Builder) {
statement.Where("email", in.Email)
}))
if err != nil && !errors.Is(err, database.ErrNoRows) {
return err
}
found := err == nil
hashed := dummyHash
if found {
hashed = user.PasswordHash
}
ok, err := hasher.CheckRaw([]byte(in.Password), hashed)
if err != nil {
return err
}
if !found || !ok {
return auth.ErrUnauthenticated
}
if err := identity.Login(request.MustSession(r), user.ID); err != nil {
return err
}
return response.Status(w, http.StatusNoContent)
}
func logout(w http.ResponseWriter, r *http.Request) error {
if err := identity.Logout(request.MustSession(r)); err != nil {
return err
} // or Destroy() to drop all data
return response.Status(w, http.StatusNoContent)
}
Cookie authentication needs CSRF protection ¶
Browsers send session cookies on cross-site requests, so install csrf.Middleware whenever SessionIdentity authenticates browser cookies.
Deleted users ¶
Sessions outlive accounts. Return ErrNoCredentials from the load function when the user no longer exists; the request becomes anonymous instead of failing with a 500.
API keys ¶
Look the key up by a non-secret id or hash, and compare secrets in constant time. Bearer validates the header, not your application's key format: even a one-byte token reaches the verifier. This application uses an eight-byte lookup prefix and stores the full token's SHA-256 hash in key.Hash. Reject short tokens before slicing them:
auth.Bearer(func(ctx context.Context, token string) (Key, error) {
const prefixLength = 8
if len(token) < prefixLength {
return Key{}, auth.ErrUnauthenticated
}
key, err := keys.ByPrefix(ctx, token[:prefixLength])
if err != nil {
return Key{}, err
}
sum := sha256.Sum256([]byte(token))
if subtle.ConstantTimeCompare(sum[:], key.Hash) != 1 {
return Key{}, auth.ErrUnauthenticated
}
return key, nil
})
keys.ByPrefix is application code. It must reject unknown or revoked keys with an error other than ErrNoCredentials, which would allow anonymous fallback. Bearer wraps lookup failures as 401 responses while retaining their causes; do not include tokens in errors that can reach logs.
JWT bearer tokens ¶
Verify the signature and claims in the verify function and build the user from the claims without a database call. The trade-off is revocation lag: a token stays valid until it expires, so keep lifetimes short.
OAuth2 and OIDC ¶
The driver/oauth/oauthauth package plugs into auth without auth importing it. For API calls, verify bearer access tokens; for browser logins, finish the flow with Callback and read it back using the same SessionIdentity:
r.Use(auth.Middleware(
auth.Bearer(client.Verify),
identity.Authenticator(),
))
r.Get("/oauth/callback", client.Callback(identity, mapIdentityToUserID))
See the package documentation of arkoren.dev/driver/oauth/oauthauth for the full setup.
Types ¶
The user is stored under its type. If Middleware uses User but Require asks for *User, the lookup finds nothing and the request fails closed with 401. Define one helper in your application and use it everywhere:
func CurrentUser(r *http.Request) (User, error) {
return request.User[User](r)
}
Events ¶
With Builder.Events, Require publishes Rejected ("http.auth.rejected") with the method of every anonymous request it rejects. The response defaults to 401; ErrorResponse can replace it. Paths are not published because they can contain identifiers or credentials. Clients control the event rate, so listeners must be cheap and a rate limiter should sit in front of Require. Payloads carry no user, credentials, query, or cause. Event contexts still retain request context values; listeners must select safe attributes. These payload limits do not change HTTP problem instance rendering.
Index ¶
- Variables
- func FromContext[U any](ctx context.Context) (U, bool)
- func Middleware[U any](authenticators ...Authenticator[U]) router.Middleware
- func NewContext[U any](ctx context.Context, user U) context.Context
- func Require[U any](configure ...BuilderFunc) router.Middleware
- type Authenticator
- type Builder
- type BuilderFunc
- type RejectedEvent
- type SessionBuilder
- type SessionBuilderFunc
- type SessionIdentity
- func (identity SessionIdentity[K, U]) Authenticator() Authenticator[U]
- func (identity SessionIdentity[K, U]) Err() error
- func (identity SessionIdentity[K, U]) Login(current *session.Session, id K) error
- func (identity SessionIdentity[K, U]) Logout(current *session.Session) error
- func (identity SessionIdentity[K, U]) Session(ctx context.Context) (*session.Session, error)
Constants ¶
This section is empty.
Variables ¶
var ErrInvalidSessionIdentity = errors.New("auth: invalid session identity")
ErrInvalidSessionIdentity reports a zero definition or nil loader. It is a configuration error, not a credential rejection, so middleware fails closed.
Example:
if err := identity.Err(); errors.Is(err, auth.ErrInvalidSessionIdentity) {
return err
}
var ErrNoCredentials = errors.New("auth: no credentials")
ErrNoCredentials is returned by an Authenticator when the request carries nothing it understands, so the next authenticator may try. Invalid credentials must return a different error to stop fallback.
Example:
if r.Header.Get("Authorization") == "" {
return User{}, auth.ErrNoCredentials
}
var ErrUnauthenticated = problem.New(func(details *problem.Builder) { details.Type("tag:arkoren.dev,2026-09:core:http/auth:unauthenticated") details.Title("Unauthenticated") details.Detail("Authentication is required to access this resource.") details.Status(http.StatusUnauthorized) })
ErrUnauthenticated is the problem returned when a request has no authenticated user or presents invalid credentials.
Example:
if _, err := request.User[User](r); err != nil {
return err // renders as 401
}
var Rejected = event.NewTopic[RejectedEvent]("http.auth.rejected")
Rejected is published when Require, built with Builder.Events, rejects an anonymous request. The default response is 401; ErrorResponse can override it. Every rejection publishes without deduplication, so a client controls the rate. Listeners must be cheap, and a rate limiter such as the ratelimit middleware should sit in front of Require. Requests that pass publish nothing.
Example:
unsubscribe, err := events.Listen(auth.Rejected, onRejected)
if err != nil {
return err
}
defer unsubscribe()
Functions ¶
func FromContext ¶
FromContext returns the user of type U stored in ctx. The boolean reports whether one was found.
Example:
if user, ok := auth.FromContext[User](r.Context()); ok {
_ = user.ID
}
func Middleware ¶
func Middleware[U any](authenticators ...Authenticator[U]) router.Middleware
Middleware tries each authenticator in order and stores the first user found in the request context. When all return ErrNoCredentials the request continues anonymously; use Require to reject it. Any other error is returned and the handler is not called.
Middleware copies the authenticator slice before returning, so replacing caller-owned entries cannot change the chain. Functions and their captured state remain shared.
Example:
r.Use(auth.Middleware( auth.NewSessionIdentity(loadUser).Authenticator(), auth.Bearer(verifyKey), ))
func NewContext ¶
NewContext returns a context carrying user. Middleware does this for requests; call it directly in tests.
Example:
ctx := auth.NewContext(context.Background(), User{ID: 1})
user, ok := auth.FromContext[User](ctx)
func Require ¶
func Require[U any](configure ...BuilderFunc) router.Middleware
Require returns a middleware that rejects requests without a user of type U in the context and, with Builder.Events, publishes Rejected. The default is 401 with a Bearer challenge; Builder.ErrorResponse can replace both the problem and its headers. A zero ErrorResponse restores the default. A user stored under a different type counts as absent, so the request fails closed. Rejection events omit paths and causes; the HTTP problem's normal instance rendering and request context values are unchanged.
Example:
api := r.With(auth.Require[User]())
Types ¶
type Authenticator ¶
Authenticator resolves the user of a request. It returns ErrNoCredentials when the request has no credentials it handles, which lets the next authenticator try. Any other error stops the chain.
Only return a user after verifying credentials. A client-supplied identity header alone is not proof of identity.
The example uses an existing router r and your application's verifyToken function, with signature func(context.Context, string) (User, error). The verifier must validate the token before returning a user and return an error other than ErrNoCredentials for an invalid, expired, or revoked token. Bearer parses the header and delegates verification to that function.
Example:
authenticate := auth.Bearer(verifyToken) r.Use(auth.Middleware(authenticate), auth.Require[User]())
func Bearer ¶
Bearer returns an Authenticator for "Authorization: Bearer <token>" requests. Without an Authorization header it returns ErrNoCredentials. A malformed header, or an error from verify, becomes ErrUnauthenticated with a `WWW-Authenticate: Bearer error="invalid_token"` challenge and the cause attached. If verify returns ErrNoCredentials it is passed on.
Example:
r.Use(auth.Middleware(auth.Bearer(func(ctx context.Context, token string) (Key, error) {
return keys.Lookup(ctx, token)
})))
type Builder ¶
type Builder struct {
// contains filtered or unexported fields
}
Builder configures Require[User] before publication. Callbacks run directly once, synchronously in order on one fresh draft; nil entries panic. Retaining the draft cannot change published settings. Methods return the same draft.
Example:
r := router.New()
r.Use(auth.Require[User](func(authenticator *auth.Builder) {
authenticator.ErrorResponse(problem.Unauthorized("Bearer"))
}))
func (*Builder) ErrorResponse ¶
ErrorResponse is the problem returned for anonymous requests. Zero uses ErrUnauthenticated with a "WWW-Authenticate: Bearer" challenge.
The setting replaces the previous value on this construction draft. Publication keeps independent settings; functions and services stay shared.
Example:
r := router.New()
r.Use(auth.Require[User](func(authenticator *auth.Builder) {
authenticator.ErrorResponse(problem.Forbidden())
}))
func (*Builder) Events ¶
Events receives Rejected when Require rejects a request. The default, nil, publishes nothing.
The setting replaces the previous value on this construction draft. Publication keeps independent settings; functions and services stay shared.
Example:
r := router.New()
r.Use(auth.Require[User](func(authenticator *auth.Builder) {
authenticator.Events(event.New())
}))
type BuilderFunc ¶
type BuilderFunc = func(*Builder)
BuilderFunc edits one Require draft synchronously. Callbacks run directly once in order; nil entries panic and expanded nil/empty slices omit edits. The completed settings detach from the draft, not event services or captures.
Example:
var policy auth.BuilderFunc = func(authenticator *auth.Builder) {
authenticator.Events(events)
}
r.Use(auth.Require[User](policy))
type RejectedEvent ¶
type RejectedEvent struct {
// Method is the client-controlled HTTP method, not a route or user identity.
// Applications must not place secrets in custom method names.
//
// Example:
// logger.InfoContext(ctx, "authentication required", "method", rejected.Method)
Method string
}
RejectedEvent reports anonymous rejection without request paths, which can contain identifiers or credentials. It carries no user, credential, header, query, or cause. Its event context still contains the request's context values.
Example:
unsubscribe, err := events.Listen(auth.Rejected, func(ctx context.Context, rejected auth.RejectedEvent) error {
logger.InfoContext(ctx, "authentication required", "method", rejected.Method)
return nil
})
if err != nil {
return err
}
defer unsubscribe()
type SessionBuilder ¶
type SessionBuilder struct {
// contains filtered or unexported fields
}
SessionBuilder selects the request session for NewSessionIdentity. Methods return the same draft. Callbacks run once in order, even with a nil loader; nil callback entries panic. Publication copies the completed selection.
Example:
identity := auth.NewSessionIdentity(loadUser, func(authenticator *auth.SessionBuilder) {
authenticator.Handle(applicationSession)
})
r.Use(auth.Middleware(identity.Authenticator()))
func (*SessionBuilder) Handle ¶
func (authenticator *SessionBuilder) Handle(handle session.Handle) *SessionBuilder
Handle selects the request bridge shared with session middleware. Zero resets the default. It changes neither cookie nor storage policy.
Example:
identity := auth.NewSessionIdentity(loadUser, func(authenticator *auth.SessionBuilder) {
authenticator.Handle(applicationSession)
})
_ = identity.Err()
type SessionBuilderFunc ¶
type SessionBuilderFunc = func(*SessionBuilder)
SessionBuilderFunc configures the session bridge captured by NewSessionIdentity. Policies run once in order on a shared draft, even for an invalid loader; a nil policy panics. This alias also accepts ordinary function values.
Example:
policy := auth.SessionBuilderFunc(func(authenticator *auth.SessionBuilder) {
authenticator.Handle(applicationSession)
})
identity := auth.NewSessionIdentity(loadUser, policy)
if err := identity.Err(); err != nil {
return err
}
r.Use(auth.Middleware(identity.Authenticator()))
type SessionIdentity ¶
type SessionIdentity[K, U any] struct { // contains filtered or unexported fields }
SessionIdentity binds the login identifier K, user loader U and session bridge in one immutable definition. Zero is invalid. Copies share functions and their captures, not mutable construction drafts. All definitions use the existing arkoren:auth:user slot: a physical session supports one identity, and another definition using the same K can still mix realms. Compatible persisted JSON may decode across types; incompatible in-memory types fail closed.
Example:
identity := auth.NewSessionIdentity(loadUser) r.Use(auth.Middleware(identity.Authenticator()))
func NewSessionIdentity ¶
func NewSessionIdentity[K, U any]( load func(ctx context.Context, id K) (U, error), configure ...SessionBuilderFunc, ) SessionIdentity[K, U]
NewSessionIdentity publishes an immutable identity definition without calling load. A nil loader is invalid, but construction callbacks still run once in order. Nil callback entries panic. Retained drafts cannot change the result; function captures remain caller-owned. Check Err during application setup.
Example:
identity := auth.NewSessionIdentity(loadUser)
if err := identity.Err(); err != nil {
return err
}
r.Use(auth.Middleware(identity.Authenticator()))
func (SessionIdentity[K, U]) Authenticator ¶
func (identity SessionIdentity[K, U]) Authenticator() Authenticator[U]
Authenticator loads U from the stored K. Missing sessions, absent identifiers and incompatible stored types yield ErrNoCredentials. Loader errors pass through unchanged. Invalid definitions instead return the configuration error before lookup, preventing middleware credential fallback.
Example:
r.Use(auth.Middleware(identity.Authenticator()))
func (SessionIdentity[K, U]) Err ¶
func (identity SessionIdentity[K, U]) Err() error
Err validates the definition without looking up a session or invoking its loader. Zero and nil-loader definitions return ErrInvalidSessionIdentity.
Example:
if err := identity.Err(); err != nil {
return err
}
func (SessionIdentity[K, U]) Login ¶
func (identity SessionIdentity[K, U]) Login(current *session.Session, id K) error
Login stores the user identifier in current and regenerates the session ID to prevent session fixation. Call it after verifying the credentials. A failed field write is returned before rotating the ID; callers must stop login. Invalid definitions fail before mutation; nil sessions return ErrNoSession. The explicit session is not checked against the definition's handle. Other data, creation time and expiry remain unchanged.
Example:
sess := request.MustSession(r)
if err := identity.Login(sess, user.ID); err != nil {
return err
}
func (SessionIdentity[K, U]) Logout ¶
func (identity SessionIdentity[K, U]) Logout(current *session.Session) error
Logout removes even a stale, wrongly typed identifier and rotates the session ID. Other data and lifetime remain unchanged; use current.Destroy for full removal. Invalid definitions fail before mutation, nil returns ErrNoSession, and deletion errors precede rotation. Explicit sessions are not handle-checked.
Example:
if err := identity.Logout(request.MustSession(r)); err != nil {
return err
}
func (SessionIdentity[K, U]) Session ¶
Session resolves exactly the selected request bridge once. It returns ErrInvalidSessionIdentity before lookup for invalid definitions and session.ErrNoSession for absent or nil sessions. It does not load a user, inspect an identifier, mutate state or fall back to the default session.
Example:
current, err := identity.Session(r.Context())
if err != nil {
return err
}
_ = current
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
Package authtest checks auth.Authenticator implementations against the rules every authenticator shares.
|
Package authtest checks auth.Authenticator implementations against the rules every authenticator shares. |
|
Package gate authorizes the authenticated user with plain Go rules.
|
Package gate authorizes the authenticated user with plain Go rules. |