golib/auth/ajwt/sign.go
AJ ONeal 2f946d28b5
ajwt: redesign API — immutable Issuer, Signer, JWKsFetcher
Key changes from previous design:

- Issuer is now immutable after construction (no mutex, no SetKeys)
- New(keys []PublicJWK) — no issURL or Validator baked in
- Verify returns (nil, err) on any failure; UnsafeVerify returns (*JWS, err)
  even on sig failure so callers can inspect kid/iss for multi-issuer routing
- VerifyAndValidate takes ClaimsValidator per-call instead of baking it into
  the Issuer; soft errors in errs, hard errors in err, nil sentinel discarded
- ClaimsValidator interface implemented by *Validator and *MultiValidator
- MultiValidator: []string for iss, aud, azp (multi-tenant)
- Signer: round-robin across NamedSigner keys via atomic.Uint64; auto-KID
  from RFC 7638 thumbprint; Issuer() returns *Issuer with signer's public keys
- JWKsFetcher: lazy, no background goroutine; Issuer(ctx) checks freshness
  per call and creates new *Issuer on cache miss; KeepOnError + StaleAge for
  serving stale keys on fetch failure
- pub.go: add EncodePublicJWK and MarshalPublicJWKs (encode counterparts)
- Remove NewWithJWKs, NewWithOIDC, NewWithOAuth2 constructors from Issuer
2026-03-13 11:23:50 -06:00

99 lines
2.8 KiB
Go

// Copyright 2025 AJ ONeal <aj@therootcompany.com> (https://therootcompany.com)
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
//
// SPDX-License-Identifier: MPL-2.0
package ajwt
import (
"crypto"
"fmt"
"sync/atomic"
)
// NamedSigner pairs a [crypto.Signer] with a key ID (KID).
//
// If KID is empty, it is auto-computed from the RFC 7638 thumbprint of the
// public key when passed to [NewSigner].
type NamedSigner struct {
KID string
Signer crypto.Signer
}
// Signer manages one or more private signing keys and issues JWTs by
// round-robining across them.
//
// Do not copy a Signer after first use — it contains an atomic counter.
type Signer struct {
signers []NamedSigner
signerIdx atomic.Uint64
}
// NewSigner creates a Signer from the provided signing keys.
//
// If a NamedSigner's KID is empty, it is auto-computed from the RFC 7638
// thumbprint of the public key. Returns an error if the slice is empty or
// a thumbprint cannot be computed.
func NewSigner(signers []NamedSigner) (*Signer, error) {
if len(signers) == 0 {
return nil, fmt.Errorf("NewSigner: at least one signer is required")
}
// Copy so the caller can't mutate after construction.
ss := make([]NamedSigner, len(signers))
copy(ss, signers)
for i, ns := range ss {
if ns.KID == "" {
jwk := PublicJWK{Key: ns.Signer.Public()}
thumb, err := jwk.Thumbprint()
if err != nil {
return nil, fmt.Errorf("NewSigner: compute thumbprint for signer[%d]: %w", i, err)
}
ss[i].KID = thumb
}
}
return &Signer{signers: ss}, nil
}
// Sign creates and signs a compact JWT from claims, using the next signing key
// in round-robin order. The caller is responsible for setting the "iss" field
// in claims if issuer identification is needed.
func (s *Signer) Sign(claims any) (string, error) {
idx := s.signerIdx.Add(1) - 1
ns := s.signers[idx%uint64(len(s.signers))]
jws, err := NewJWSFromClaims(claims, ns.KID)
if err != nil {
return "", err
}
if _, err := jws.Sign(ns.Signer); err != nil {
return "", err
}
return jws.Encode(), nil
}
// Issuer returns a new [*Issuer] containing the public keys of all signing keys.
//
// Use this to construct an Issuer for verifying tokens signed by this Signer.
// For key rotation, combine with old public keys:
//
// iss := ajwt.New(append(signer.PublicKeys(), oldKeys...))
func (s *Signer) Issuer() *Issuer {
return New(s.PublicKeys())
}
// PublicKeys returns the public-key side of each signing key, in the same order
// as the signers were provided to [NewSigner].
func (s *Signer) PublicKeys() []PublicJWK {
keys := make([]PublicJWK, len(s.signers))
for i, ns := range s.signers {
keys[i] = PublicJWK{
Key: ns.Signer.Public(),
KID: ns.KID,
}
}
return keys
}