e75c8b37c8
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
162 lines
5.5 KiB
Go
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
|
|
}
|