Mint and verify short-lived RS256 user tokens.
ExApps sign after AppAPI auth; Microservices check a static public key or an OIDC issuer and forward the same bearer. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
package usertoken
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rsa"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config selects one verify mode.
|
||||
// Set PublicKey for static RS256, or OIDCIssuer for discovery. Not both.
|
||||
// Skew of zero uses 1 minute. GroupsClaim empty uses "groups" (OIDC only).
|
||||
// Audience empty is allowed only for OIDC, where it skips the audience check.
|
||||
type Config struct {
|
||||
PublicKey *rsa.PublicKey
|
||||
Issuer string
|
||||
Audience string
|
||||
OIDCIssuer string
|
||||
OIDCAudience string
|
||||
GroupsClaim string
|
||||
Skew time.Duration
|
||||
}
|
||||
|
||||
// Auth verifies bearers for one Microservice process.
|
||||
type Auth struct {
|
||||
v tokenVerifier
|
||||
}
|
||||
|
||||
// FromEnv reads the process environment. See the authentication procedure for names.
|
||||
// Both USER_TOKEN_PUBLIC_KEY_FILE and OIDC_ISSUER set, or neither set, is an error.
|
||||
func FromEnv(ctx context.Context) (*Auth, error) {
|
||||
pubFile := strings.TrimSpace(os.Getenv("USER_TOKEN_PUBLIC_KEY_FILE"))
|
||||
oidcIss := strings.TrimSpace(os.Getenv("OIDC_ISSUER"))
|
||||
switch {
|
||||
case pubFile != "" && oidcIss != "":
|
||||
return nil, fmt.Errorf("set USER_TOKEN_PUBLIC_KEY_FILE or OIDC_ISSUER, not both")
|
||||
case pubFile == "" && oidcIss == "":
|
||||
return nil, fmt.Errorf("set USER_TOKEN_PUBLIC_KEY_FILE or OIDC_ISSUER")
|
||||
}
|
||||
var skew time.Duration
|
||||
if raw := strings.TrimSpace(os.Getenv("USER_TOKEN_SKEW")); raw != "" {
|
||||
var err error
|
||||
skew, err = time.ParseDuration(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("USER_TOKEN_SKEW: %w", err)
|
||||
}
|
||||
}
|
||||
cfg := Config{
|
||||
Issuer: os.Getenv("USER_TOKEN_ISSUER"),
|
||||
Audience: os.Getenv("USER_TOKEN_AUDIENCE"),
|
||||
OIDCIssuer: oidcIss,
|
||||
OIDCAudience: os.Getenv("OIDC_AUDIENCE"),
|
||||
GroupsClaim: os.Getenv("OIDC_GROUPS_CLAIM"),
|
||||
Skew: skew,
|
||||
}
|
||||
if pubFile != "" {
|
||||
key, err := readPublicKeyFile(pubFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.PublicKey = key
|
||||
}
|
||||
return New(ctx, cfg)
|
||||
}
|
||||
|
||||
// New builds an Auth from explicit configuration. ctx is used for OIDC discovery.
|
||||
func New(ctx context.Context, cfg Config) (*Auth, error) {
|
||||
static := cfg.PublicKey != nil
|
||||
oidcOn := strings.TrimSpace(cfg.OIDCIssuer) != ""
|
||||
switch {
|
||||
case static && oidcOn:
|
||||
return nil, fmt.Errorf("set a public key or an OIDC issuer, not both")
|
||||
case !static && !oidcOn:
|
||||
return nil, fmt.Errorf("set a public key or an OIDC issuer")
|
||||
}
|
||||
var v tokenVerifier
|
||||
var err error
|
||||
if static {
|
||||
v, err = newStaticVerifier(cfg.PublicKey, cfg.Issuer, cfg.Audience, cfg.Skew)
|
||||
} else {
|
||||
v, err = newOIDCVerifier(ctx, cfg.OIDCIssuer, cfg.OIDCAudience, cfg.GroupsClaim, cfg.Skew)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Auth{v: v}, nil
|
||||
}
|
||||
|
||||
// Verify checks a raw JWT (no "Bearer " prefix).
|
||||
func (a *Auth) Verify(ctx context.Context, raw string) (Caller, error) {
|
||||
return a.v.verify(ctx, raw)
|
||||
}
|
||||
|
||||
// Middleware requires a bearer on every path except /health.
|
||||
// Success stores the Caller and the raw JWT on the request context.
|
||||
func (a *Auth) Middleware() func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/health" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
raw, ok := bearerToken(r.Header.Get("Authorization"))
|
||||
if !ok {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
caller, err := a.Verify(r.Context(), raw)
|
||||
if err != nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(withAuth(r.Context(), caller, raw)))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func bearerToken(h string) (string, bool) {
|
||||
scheme, rest, ok := strings.Cut(h, " ")
|
||||
if !ok || !strings.EqualFold(scheme, "Bearer") {
|
||||
return "", false
|
||||
}
|
||||
rest = strings.TrimSpace(rest)
|
||||
if rest == "" {
|
||||
return "", false
|
||||
}
|
||||
return rest, true
|
||||
}
|
||||
Reference in New Issue
Block a user