Files
warmbly/internal/api/middleware/oidc.go
T

90 lines
2.0 KiB
Go

package middleware
import (
"strings"
"time"
"github.com/MicahParks/keyfunc/v3"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/warmbly/warmbly/internal/errx"
)
const (
googleIssuer = "https://accounts.google.com"
)
type OidcHandler struct {
ServiceAccount string
KeySet keyfunc.Keyfunc
AppEnv string
// Audience is this instance's public URL, which Cloud Tasks puts in the
// token it mints for the webhook. Without it any token that service
// account holds for any audience is accepted here. Empty leaves the check
// off, for a deployment that cannot name its own URL.
Audience string
}
func (h *OidcHandler) Middleware() gin.HandlerFunc {
return func(c *gin.Context) {
if h.AppEnv == "dev" {
c.Next()
return
}
// No key set means the GCP Cloud Tasks OIDC path isn't configured (the
// default local dispatcher calls handlers in-process). Fail closed
// rather than deref a nil key set if a webhook request slips through.
if h.KeySet == nil {
errx.Handle(c, errx.ErrForbidden)
return
}
auth := c.GetHeader("Authorization")
if auth == "" || !strings.HasPrefix(auth, "Bearer ") {
errx.Handle(c, errx.ErrForbidden)
return
}
tokenStr := strings.TrimPrefix(auth, "Bearer ")
opts := []jwt.ParserOption{
jwt.WithLeeway(10 * time.Second),
jwt.WithValidMethods([]string{"RS256"}),
jwt.WithExpirationRequired(),
}
if h.Audience != "" {
opts = append(opts, jwt.WithAudience(h.Audience))
}
token, err := jwt.Parse(tokenStr, h.KeySet.Keyfunc, opts...)
if err != nil {
errx.Handle(c, errx.ErrForbidden)
return
}
if !token.Valid {
errx.Handle(c, errx.ErrForbidden)
return
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
errx.Handle(c, errx.ErrForbidden)
return
}
if iss, _ := claims.GetIssuer(); iss != googleIssuer {
errx.Handle(c, errx.ErrForbidden)
return
}
if sAccount, _ := claims.GetSubject(); sAccount != h.ServiceAccount {
errx.Handle(c, errx.ErrForbidden)
return
}
c.Next()
}
}