authkit initial

This commit is contained in:
juancwu 2026-04-26 01:36:53 +00:00
commit 134393fbca
43 changed files with 5188 additions and 1 deletions

71
middleware/authz.go Normal file
View file

@ -0,0 +1,71 @@
package middleware
import (
"net/http"
"git.juancwu.dev/juancwu/authkit"
)
// authzGuard wraps the common pattern of "look up the Principal, run a
// predicate, succeed or 403". onForbidden defaults to JSON 403.
func authzGuard(onForbidden func(http.ResponseWriter, *http.Request, error), pred func(*authkit.Principal) bool) func(http.Handler) http.Handler {
if onForbidden == nil {
onForbidden = defaultJSONError(http.StatusForbidden)
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p, ok := PrincipalFrom(r.Context())
if !ok {
// No auth middleware ran upstream; treat as forbidden
// rather than crashing — composition is the caller's
// responsibility but a 403 is the safer default.
onForbidden(w, r, authkit.ErrPermissionDenied)
return
}
if !pred(p) {
onForbidden(w, r, authkit.ErrPermissionDenied)
return
}
next.ServeHTTP(w, r)
})
}
}
// RequireRole permits requests whose Principal holds the named role.
func RequireRole(name string, onForbidden ...func(http.ResponseWriter, *http.Request, error)) func(http.Handler) http.Handler {
return authzGuard(firstOrNil(onForbidden), func(p *authkit.Principal) bool {
return p.HasRole(name)
})
}
// RequireAnyRole permits requests whose Principal holds at least one of the
// named roles.
func RequireAnyRole(names []string, onForbidden ...func(http.ResponseWriter, *http.Request, error)) func(http.Handler) http.Handler {
return authzGuard(firstOrNil(onForbidden), func(p *authkit.Principal) bool {
return p.HasAnyRole(names...)
})
}
// RequirePermission permits requests whose Principal holds the named
// permission (resolved via roles at auth time).
func RequirePermission(name string, onForbidden ...func(http.ResponseWriter, *http.Request, error)) func(http.Handler) http.Handler {
return authzGuard(firstOrNil(onForbidden), func(p *authkit.Principal) bool {
return p.HasPermission(name)
})
}
// RequireAbility permits requests whose Principal carries the named ability.
// Abilities are populated only for API-key authentication; this middleware
// will reject session/JWT-authenticated requests by design.
func RequireAbility(name string, onForbidden ...func(http.ResponseWriter, *http.Request, error)) func(http.Handler) http.Handler {
return authzGuard(firstOrNil(onForbidden), func(p *authkit.Principal) bool {
return p.HasAbility(name)
})
}
func firstOrNil(s []func(http.ResponseWriter, *http.Request, error)) func(http.ResponseWriter, *http.Request, error) {
if len(s) == 0 {
return nil
}
return s[0]
}

39
middleware/context.go Normal file
View file

@ -0,0 +1,39 @@
// Package middleware provides framework-neutral HTTP middleware for authkit.
// Every middleware function returns the standard func(http.Handler)
// http.Handler type, so it composes with lightmux's Use/Group/Handle as well
// as any net/http stack that uses the same signature.
package middleware
import (
"context"
"net/http"
"git.juancwu.dev/juancwu/authkit"
)
// principalKey is an unexported context key. Using a distinct empty struct
// type guarantees no collision with caller-defined keys.
type principalKey struct{}
// withPrincipal stashes p on the request context for downstream handlers.
func withPrincipal(ctx context.Context, p *authkit.Principal) context.Context {
return context.WithValue(ctx, principalKey{}, p)
}
// PrincipalFrom retrieves the authenticated Principal placed by RequireSession,
// RequireJWT, or RequireAPIKey. The boolean is false if no auth middleware
// ran for this request.
func PrincipalFrom(ctx context.Context) (*authkit.Principal, bool) {
p, ok := ctx.Value(principalKey{}).(*authkit.Principal)
return p, ok
}
// MustPrincipal panics if no Principal is on the context. Use only on
// handlers known to be behind a Require* middleware.
func MustPrincipal(r *http.Request) *authkit.Principal {
p, ok := PrincipalFrom(r.Context())
if !ok {
panic("authkit/middleware: no principal on request context")
}
return p
}

138
middleware/middleware.go Normal file
View file

@ -0,0 +1,138 @@
package middleware
import (
"encoding/json"
"net/http"
"git.juancwu.dev/juancwu/authkit"
)
// Options configures auth middleware. Auth is required; the rest fall back
// to defaults: BearerExtractor, a JSON 401 on auth failure, and a JSON 403
// on authz failure.
type Options struct {
Auth *authkit.Auth
Extractor authkit.Extractor
OnUnauth func(w http.ResponseWriter, r *http.Request, err error)
OnForbidden func(w http.ResponseWriter, r *http.Request, err error)
}
func (o Options) extractor() authkit.Extractor {
if o.Extractor != nil {
return o.Extractor
}
return authkit.BearerExtractor()
}
func (o Options) onUnauth() func(w http.ResponseWriter, r *http.Request, err error) {
if o.OnUnauth != nil {
return o.OnUnauth
}
return defaultJSONError(http.StatusUnauthorized)
}
func (o Options) onForbidden() func(w http.ResponseWriter, r *http.Request, err error) {
if o.OnForbidden != nil {
return o.OnForbidden
}
return defaultJSONError(http.StatusForbidden)
}
func defaultJSONError(status int) func(w http.ResponseWriter, r *http.Request, err error) {
return func(w http.ResponseWriter, _ *http.Request, err error) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]string{
"error": http.StatusText(status),
})
}
}
// RequireSession authenticates the request via an opaque session string. The
// extractor is consulted first; if no extractor is set the default Bearer
// extractor is used. For cookie-based session lookup, set
// Options.Extractor = authkit.CookieExtractor(cfg.SessionCookieName).
func RequireSession(opts Options) func(http.Handler) http.Handler {
return requireWith(opts, func(r *http.Request, raw string) (*authkit.Principal, error) {
return opts.Auth.AuthenticateSession(r.Context(), raw)
})
}
// RequireJWT authenticates the request via an HS256 JWT.
func RequireJWT(opts Options) func(http.Handler) http.Handler {
return requireWith(opts, func(r *http.Request, raw string) (*authkit.Principal, error) {
return opts.Auth.AuthenticateJWT(r.Context(), raw)
})
}
// RequireAPIKey authenticates the request via an opaque API secret.
func RequireAPIKey(opts Options) func(http.Handler) http.Handler {
return requireWith(opts, func(r *http.Request, raw string) (*authkit.Principal, error) {
return opts.Auth.AuthenticateAPIKey(r.Context(), raw)
})
}
// RequireAny tries each method in order until one succeeds. Useful for routes
// that accept either a session cookie or an API key.
func RequireAny(opts Options, methods ...authkit.AuthMethod) func(http.Handler) http.Handler {
if len(methods) == 0 {
methods = []authkit.AuthMethod{
authkit.AuthMethodSession,
authkit.AuthMethodJWT,
authkit.AuthMethodAPIKey,
}
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw, ok := opts.extractor()(r)
if !ok || raw == "" {
opts.onUnauth()(w, r, authkit.ErrSessionInvalid)
return
}
var (
p *authkit.Principal
lastErr error
)
for _, m := range methods {
switch m {
case authkit.AuthMethodSession:
p, lastErr = opts.Auth.AuthenticateSession(r.Context(), raw)
case authkit.AuthMethodJWT:
p, lastErr = opts.Auth.AuthenticateJWT(r.Context(), raw)
case authkit.AuthMethodAPIKey:
p, lastErr = opts.Auth.AuthenticateAPIKey(r.Context(), raw)
}
if lastErr == nil && p != nil {
next.ServeHTTP(w, r.WithContext(withPrincipal(r.Context(), p)))
return
}
}
opts.onUnauth()(w, r, lastErr)
})
}
}
// requireWith is the shared scaffolding for the single-method Require*
// middlewares.
func requireWith(opts Options, authn func(r *http.Request, raw string) (*authkit.Principal, error)) func(http.Handler) http.Handler {
if opts.Auth == nil {
panic("authkit/middleware: Options.Auth is required")
}
extractor := opts.extractor()
onUnauth := opts.onUnauth()
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw, ok := extractor(r)
if !ok || raw == "" {
onUnauth(w, r, authkit.ErrSessionInvalid)
return
}
p, err := authn(r, raw)
if err != nil {
onUnauth(w, r, err)
return
}
next.ServeHTTP(w, r.WithContext(withPrincipal(r.Context(), p)))
})
}
}