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 and builds an Auth. // Set USER_TOKEN_PUBLIC_KEY_FILE or OIDC_ISSUER, not both and not neither. // Static mode also reads USER_TOKEN_ISSUER and USER_TOKEN_AUDIENCE. // OIDC mode reads OIDC_AUDIENCE and optional OIDC_GROUPS_CLAIM. // Optional USER_TOKEN_SKEW is a time.Duration; empty uses 1 minute. // It returns an error when the mode is ambiguous, the key file is unreadable, // USER_TOKEN_SKEW cannot be parsed, or OIDC discovery fails. // ctx is used for OIDC discovery. 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. raw has no "Bearer " prefix. // A failed check wraps ErrUnauthorized. // Startup and configuration failures do not. 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 }