package usertoken import ( "crypto/rsa" "fmt" "os" "strings" "time" "github.com/golang-jwt/jwt/v5" ) const defaultTTL = 5 * time.Minute // SignConfig is the ExApp mint configuration. // TTL of zero uses 5 minutes. type SignConfig struct { PrivateKey *rsa.PrivateKey Issuer string Audience string TTL time.Duration } // MintInput is one user token. // Groups nil omits the claim. A non-nil pointer includes it, including an empty list. type MintInput struct { Subject string Groups *[]string } // Signer mints RS256 user tokens. Only the ExApp should hold one. type Signer struct { key *rsa.PrivateKey iss string aud string ttl time.Duration } // NewSignerFromEnv reads USER_TOKEN_PRIVATE_KEY_FILE, USER_TOKEN_ISSUER, // USER_TOKEN_AUDIENCE, and optional USER_TOKEN_TTL. func NewSignerFromEnv() (*Signer, error) { path := strings.TrimSpace(os.Getenv("USER_TOKEN_PRIVATE_KEY_FILE")) if path == "" { return nil, fmt.Errorf("USER_TOKEN_PRIVATE_KEY_FILE is empty") } key, err := readPrivateKeyFile(path) if err != nil { return nil, err } var ttl time.Duration if raw := strings.TrimSpace(os.Getenv("USER_TOKEN_TTL")); raw != "" { ttl, err = time.ParseDuration(raw) if err != nil { return nil, fmt.Errorf("USER_TOKEN_TTL: %w", err) } } return NewSigner(SignConfig{ PrivateKey: key, Issuer: os.Getenv("USER_TOKEN_ISSUER"), Audience: os.Getenv("USER_TOKEN_AUDIENCE"), TTL: ttl, }) } // NewSigner checks the key, issuer, and audience. func NewSigner(cfg SignConfig) (*Signer, error) { if cfg.PrivateKey == nil { return nil, fmt.Errorf("private key is nil") } iss := strings.TrimSpace(cfg.Issuer) aud := strings.TrimSpace(cfg.Audience) if iss == "" || aud == "" { return nil, fmt.Errorf("issuer and audience are required") } ttl := cfg.TTL if ttl <= 0 { ttl = defaultTTL } return &Signer{key: cfg.PrivateKey, iss: iss, aud: aud, ttl: ttl}, nil } type tokenClaims struct { jwt.RegisteredClaims PreferredUsername string `json:"preferred_username"` Groups *[]string `json:"groups,omitempty"` } // Mint signs one token at now. Subject becomes sub and preferred_username. func (s *Signer) Mint(now time.Time, in MintInput) (string, error) { subject := strings.TrimSpace(in.Subject) if subject == "" { return "", fmt.Errorf("empty subject") } claims := tokenClaims{ Issuer: s.iss, Subject: subject, Audience: jwt.ClaimStrings{s.aud}, IssuedAt: jwt.NewNumericDate(now), ExpiresAt: jwt.NewNumericDate(now.Add(s.ttl)), PreferredUsername: subject, Groups: in.Groups, } signed, err := jwt.NewWithClaims(jwt.SigningMethodRS256, claims).SignedString(s.key) if err != nil { return "", fmt.Errorf("sign: %w", err) } return signed, nil }