You've already forked agentic-coding-workflow
feat(http/mw): bearer token auth middleware with optional mode
This commit is contained in:
@@ -5,6 +5,7 @@ go 1.25.0
|
|||||||
require (
|
require (
|
||||||
github.com/go-chi/chi/v5 v5.2.5
|
github.com/go-chi/chi/v5 v5.2.5
|
||||||
github.com/golang-migrate/migrate/v4 v4.19.1
|
github.com/golang-migrate/migrate/v4 v4.19.1
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
github.com/jackc/pgx/v5 v5.9.2
|
github.com/jackc/pgx/v5 v5.9.2
|
||||||
github.com/oklog/ulid/v2 v2.1.1
|
github.com/oklog/ulid/v2 v2.1.1
|
||||||
github.com/spf13/viper v1.21.0
|
github.com/spf13/viper v1.21.0
|
||||||
@@ -34,7 +35,6 @@ require (
|
|||||||
github.com/go-logr/stdr v1.2.2 // indirect
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SessionResolver is the contract Auth depends on to translate a bearer
|
||||||
|
// token into an authenticated user ID. Defining the dependency as an
|
||||||
|
// interface here (rather than importing user.Service directly) keeps the
|
||||||
|
// transport layer free of any concrete user-package import, which is
|
||||||
|
// essential because the user module is implemented in a later phase and
|
||||||
|
// this middleware must be writeable and testable without it.
|
||||||
|
//
|
||||||
|
// Implementations must:
|
||||||
|
// - return (uid, true, nil) on a valid session,
|
||||||
|
// - return (uuid.Nil, false, nil) for a syntactically valid but unknown
|
||||||
|
// or expired token (the middleware treats this the same as an error
|
||||||
|
// for the client: 401),
|
||||||
|
// - return a non-nil error for unexpected failures (e.g. database
|
||||||
|
// outage); the middleware also returns 401 to avoid leaking which
|
||||||
|
// branch failed.
|
||||||
|
type SessionResolver interface {
|
||||||
|
ResolveSession(ctx context.Context, token string) (userID uuid.UUID, ok bool, err error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthOptions configures the Auth middleware. A zero value means
|
||||||
|
// "authentication required". Optional=true is intended for endpoints
|
||||||
|
// where an anonymous caller is allowed (e.g. a public landing page that
|
||||||
|
// personalises content when a user happens to be signed in).
|
||||||
|
//
|
||||||
|
// The struct exists rather than a bare bool so future extensions
|
||||||
|
// (RBAC scope checks, multi-realm support, etc.) can be added without
|
||||||
|
// breaking call sites.
|
||||||
|
type AuthOptions struct {
|
||||||
|
// Optional, when true, lets requests without an Authorization header
|
||||||
|
// pass through with no user_id in the context. Requests that DO carry
|
||||||
|
// a token are still validated; an invalid token is rejected even when
|
||||||
|
// Optional is set, so a forged token cannot bypass enforcement.
|
||||||
|
Optional bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// userIDKey is the context key used to attach the authenticated user ID
|
||||||
|
// to the request context. It deliberately reuses the ctxKey type defined
|
||||||
|
// in request_id.go (value 1) but with a distinct integer value (2) so
|
||||||
|
// keys cannot collide while still keeping the type unexported to the
|
||||||
|
// middleware package.
|
||||||
|
const userIDKey ctxKey = 2
|
||||||
|
|
||||||
|
// Auth returns HTTP middleware that enforces bearer token authentication
|
||||||
|
// using the supplied SessionResolver. On success the resolved user ID is
|
||||||
|
// stored in the request context and downstream handlers can retrieve it
|
||||||
|
// via UserIDFromContext.
|
||||||
|
//
|
||||||
|
// Failure modes all collapse to a 401 response with a small JSON body so
|
||||||
|
// the client cannot distinguish between "missing token", "malformed
|
||||||
|
// header", "unknown token" and "resolver failure" — this is intentional
|
||||||
|
// to limit the information available to attackers probing for valid
|
||||||
|
// tokens.
|
||||||
|
func Auth(resolver SessionResolver, opt AuthOptions) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
token := extractBearer(r)
|
||||||
|
if token == "" {
|
||||||
|
if opt.Optional {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, `{"code":"unauthorized","message":"missing token"}`, http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uid, ok, err := resolver.ResolveSession(r.Context(), token)
|
||||||
|
if err != nil || !ok {
|
||||||
|
http.Error(w, `{"code":"unauthorized","message":"invalid token"}`, http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := context.WithValue(r.Context(), userIDKey, uid)
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserIDFromContext returns the authenticated user ID stored in ctx by
|
||||||
|
// the Auth middleware. The boolean is false when no Auth middleware ran
|
||||||
|
// upstream or the request was admitted via AuthOptions.Optional with no
|
||||||
|
// token, so callers can distinguish "anonymous" from "logged-in user
|
||||||
|
// whose ID happens to be the zero UUID".
|
||||||
|
func UserIDFromContext(ctx context.Context) (uuid.UUID, bool) {
|
||||||
|
v, ok := ctx.Value(userIDKey).(uuid.UUID)
|
||||||
|
return v, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractBearer parses an Authorization header of the form "Bearer <tok>"
|
||||||
|
// and returns the trimmed token. It returns an empty string when the
|
||||||
|
// header is missing or does not start with the canonical "Bearer "
|
||||||
|
// prefix; case-insensitive matching is intentionally NOT performed
|
||||||
|
// because RFC 6750 specifies the literal "Bearer" scheme name and being
|
||||||
|
// strict here keeps the parsing surface small.
|
||||||
|
func extractBearer(r *http.Request) string {
|
||||||
|
h := r.Header.Get("Authorization")
|
||||||
|
const prefix = "Bearer "
|
||||||
|
if !strings.HasPrefix(h, prefix) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(h[len(prefix):])
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeAuth is a stub SessionResolver used by the auth middleware tests.
|
||||||
|
// It returns either the configured user (ok=true) or the configured error,
|
||||||
|
// so each test can target a specific branch in Auth without spinning up
|
||||||
|
// the real user.Service (which lives in a later phase).
|
||||||
|
type fakeAuth struct {
|
||||||
|
user uuid.UUID
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f fakeAuth) ResolveSession(_ context.Context, _ string) (uuid.UUID, bool, error) {
|
||||||
|
if f.err != nil {
|
||||||
|
return uuid.Nil, false, f.err
|
||||||
|
}
|
||||||
|
return f.user, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuth_NoTokenAllowedWhenOptional(t *testing.T) {
|
||||||
|
called := false
|
||||||
|
mw := Auth(fakeAuth{}, AuthOptions{Optional: true})
|
||||||
|
h := mw(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
||||||
|
called = true
|
||||||
|
_, ok := UserIDFromContext(r.Context())
|
||||||
|
require.False(t, ok)
|
||||||
|
}))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, httptest.NewRequest("GET", "/", nil))
|
||||||
|
require.True(t, called)
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuth_NoTokenRejectedWhenRequired(t *testing.T) {
|
||||||
|
mw := Auth(fakeAuth{}, AuthOptions{})
|
||||||
|
h := mw(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||||
|
t.Fatal("handler should not be called")
|
||||||
|
}))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, httptest.NewRequest("GET", "/", nil))
|
||||||
|
require.Equal(t, http.StatusUnauthorized, rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuth_ValidTokenPopulatesContext(t *testing.T) {
|
||||||
|
uid := uuid.New()
|
||||||
|
mw := Auth(fakeAuth{user: uid}, AuthOptions{})
|
||||||
|
h := mw(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
||||||
|
got, ok := UserIDFromContext(r.Context())
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Equal(t, uid, got)
|
||||||
|
}))
|
||||||
|
req := httptest.NewRequest("GET", "/", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer tok")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuth_BadTokenReturns401(t *testing.T) {
|
||||||
|
mw := Auth(fakeAuth{err: errors.New("invalid")}, AuthOptions{})
|
||||||
|
h := mw(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||||
|
t.Fatal("handler should not be called")
|
||||||
|
}))
|
||||||
|
req := httptest.NewRequest("GET", "/", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer x")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
require.Equal(t, http.StatusUnauthorized, rec.Code)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user