Files
go-usertoken/auth.go
T
konradandCursor f082561cc6 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>
2026-09-28 14:03:52 +02:00

132 lines
3.7 KiB
Go

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
}