Files
kandev/apps/backend/internal/agentctl/server/api/auth.go
T

162 lines
5.5 KiB
Go

package api
import (
"crypto/subtle"
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
// Shared auth-rejection messages, reused by bearerTokenAuth, instanceAuth,
// and controlCredentialAuth (credential_rotation.go) so the three auth
// checks -- static token, per-instance dynamic credential, and control-plane
// rotating credential -- report identical wording (goconst: 3+ occurrences).
const (
errMissingAuthHeader = "missing or invalid Authorization header"
errInvalidAuthToken = "invalid auth token"
)
// credentialInvalidatedContextKey is the gin context key instanceAuth uses to
// hand a stream handler the invalidation channel for the exact credential
// generation that authenticated the request. A stream handler must read it
// from here rather than calling credentialSource.Invalidated() itself: that
// would be a second, independent lock acquisition, and a rotation landing
// between the two could authenticate the request against the generation it
// just superseded while handing the handler a channel for the generation
// that replaced it -- a channel that never closes for the rotation that
// actually invalidated the accepted credential (AC-EXECUTORS-CONTROL-
// OWNERSHIP-002.2).
const credentialInvalidatedContextKey = "credentialInvalidated"
// afterInstanceAuthAccepted is invoked, if non-nil, immediately after
// instanceAuth accepts a credentialSource-backed request and captures its
// invalidation channel, before the request is allowed to proceed. It exists
// solely so a test can deterministically land a concurrent rotation in this
// exact window; always nil in production.
var afterInstanceAuthAccepted func()
// bearerTokenAuth returns a gin middleware that validates a Bearer token
// on every request except the exempted paths (e.g., /health).
// If expectedToken is empty, authentication is disabled (no-op middleware).
func bearerTokenAuth(expectedToken string, exemptPaths ...string) gin.HandlerFunc {
if expectedToken == "" {
return func(c *gin.Context) { c.Next() }
}
exempt := make(map[string]bool, len(exemptPaths))
for _, p := range exemptPaths {
exempt[p] = true
}
return func(c *gin.Context) {
if exempt[c.Request.URL.Path] {
c.Next()
return
}
token := extractBearerToken(c.Request)
if token == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{errKey: errMissingAuthHeader})
return
}
if !tokenEqual(token, expectedToken) {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{errKey: errInvalidAuthToken})
return
}
c.Next()
}
}
// instanceAuth returns a gin middleware validating a Bearer token on every
// request except the exempted paths, exactly like bearerTokenAuth, but reads
// its accepted token dynamically on each request rather than capturing one
// at registration time.
//
// When s.credentialSource is set (via SetCredentialSource), it authenticates
// against the control server's current highest-numbered rotation instead of
// staticToken -- the single-credential model of design 01 "Single driver"
// (AC-EXECUTORS-CONTROL-OWNERSHIP-002.6). s.credentialSource is read fresh
// per request rather than baked into a closure at Use()-time because
// SetCredentialSource is only ever called after NewServer returns, once the
// caller has a control server to wire it to.
//
// When credentialSource is nil, behavior is identical to
// bearerTokenAuth(staticToken, exemptPaths...), including "empty token
// disables auth" -- every existing test constructing a Server directly is
// unaffected.
func (s *Server) instanceAuth(staticToken string, exemptPaths ...string) gin.HandlerFunc {
exempt := make(map[string]bool, len(exemptPaths))
for _, p := range exemptPaths {
exempt[p] = true
}
return func(c *gin.Context) {
if exempt[c.Request.URL.Path] {
c.Next()
return
}
if s.credentialSource == nil && staticToken == "" {
c.Next()
return
}
token := extractBearerToken(c.Request)
if token == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{errKey: errMissingAuthHeader})
return
}
if s.credentialSource != nil {
accepted, invalidated := s.credentialSource.AcceptsFullWithInvalidation(token)
if !accepted {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{errKey: errInvalidAuthToken})
return
}
if afterInstanceAuthAccepted != nil {
afterInstanceAuthAccepted()
}
c.Set(credentialInvalidatedContextKey, invalidated)
c.Next()
return
}
if !tokenEqual(token, staticToken) {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{errKey: errInvalidAuthToken})
return
}
c.Next()
}
}
// credentialInvalidatedFromContext reads the invalidation channel instanceAuth
// captured atomically with its accept check (see credentialInvalidatedContextKey).
// Returns nil when no credentialSource is wired -- the caller's watcher
// goroutine is then correctly skipped, matching legacy no-control-server
// behavior.
func credentialInvalidatedFromContext(c *gin.Context) <-chan struct{} {
v, ok := c.Get(credentialInvalidatedContextKey)
if !ok {
return nil
}
ch, _ := v.(<-chan struct{})
return ch
}
// extractBearerToken extracts the token from the Authorization header.
func extractBearerToken(r *http.Request) string {
const prefix = "Bearer "
if auth := r.Header.Get("Authorization"); strings.HasPrefix(auth, prefix) {
return auth[len(prefix):]
}
return ""
}
// tokenEqual compares two tokens in constant time to prevent timing attacks.
func tokenEqual(a, b string) bool {
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
}