chore: Add JWT() method for convenience and clarity (#11067)

This slightly simplifies calling code by centralizing the common 3-liner to create a JWT from claims, signed by a key.

But more importantly, it reduces the risk of `key.PreProcessToken()` being forgotten, which will become relevant in upcoming PRs:

`key.PreProcessToken()` adds the key id to the JWT header, which is important to efficiently validate tokens when multiple validation keys are supported (that is not the case yet)

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/11067
Co-authored-by: Nils Goroll <nils.goroll@uplex.de>
Co-committed-by: Nils Goroll <nils.goroll@uplex.de>
This commit is contained in:
Nils Goroll 2026-02-07 01:01:30 +01:00 committed by Gusted
parent 80161b9fd3
commit 180bd488e1
4 changed files with 66 additions and 10 deletions

View file

@ -34,6 +34,12 @@ func (err ErrInvalidAlgorithmType) Error() string {
return fmt.Sprintf("JWT signing algorithm is not supported: %s", err.Algorithm)
}
func jwtHelper(key SigningKey, claims jwt.Claims, opts ...jwt.TokenOption) (string, error) {
jwt := jwt.NewWithClaims(key.SigningMethod(), claims, opts...)
key.PreProcessToken(jwt)
return jwt.SignedString(key.SignKey())
}
// SigningKey represents a algorithm/key pair to sign JWTs
type SigningKey interface {
IsSymmetric() bool
@ -42,6 +48,8 @@ type SigningKey interface {
VerifyKey() any
ToJWK() (map[string]string, error)
PreProcessToken(*jwt.Token)
// convenience: jwt.NewWithClaims + PreProcessToken + SignedString
JWT(jwt.Claims, ...jwt.TokenOption) (string, error)
}
type hmacSigningKey struct {
@ -74,6 +82,10 @@ func (key hmacSigningKey) ToJWK() (map[string]string, error) {
func (key hmacSigningKey) PreProcessToken(*jwt.Token) {}
func (key hmacSigningKey) JWT(claims jwt.Claims, opts ...jwt.TokenOption) (string, error) {
return jwtHelper(key, claims, opts...)
}
type rsaSigningKey struct {
signingMethod jwt.SigningMethod
key *rsa.PrivateKey
@ -125,6 +137,10 @@ func (key rsaSigningKey) PreProcessToken(token *jwt.Token) {
token.Header["kid"] = key.id
}
func (key rsaSigningKey) JWT(claims jwt.Claims, opts ...jwt.TokenOption) (string, error) {
return jwtHelper(key, claims, opts...)
}
type eddsaSigningKey struct {
signingMethod jwt.SigningMethod
key ed25519.PrivateKey
@ -176,6 +192,10 @@ func (key eddsaSigningKey) PreProcessToken(token *jwt.Token) {
token.Header["kid"] = key.id
}
func (key eddsaSigningKey) JWT(claims jwt.Claims, opts ...jwt.TokenOption) (string, error) {
return jwtHelper(key, claims, opts...)
}
type ecdsaSigningKey struct {
signingMethod jwt.SigningMethod
key *ecdsa.PrivateKey
@ -228,6 +248,10 @@ func (key ecdsaSigningKey) PreProcessToken(token *jwt.Token) {
token.Header["kid"] = key.id
}
func (key ecdsaSigningKey) JWT(claims jwt.Claims, opts ...jwt.TokenOption) (string, error) {
return jwtHelper(key, claims, opts...)
}
var allowedAlgorithms = map[string]bool{
"HS256": true,
"HS384": true,

View file

@ -13,6 +13,7 @@ import (
"path/filepath"
"testing"
"github.com/golang-jwt/jwt/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@ -112,6 +113,44 @@ func TestLoadOrCreateAsymmetricKey(t *testing.T) {
})
}
type testClaims struct {
Foo string `json:"Foo"`
jwt.RegisteredClaims
}
func TestJWTHasKid(t *testing.T) {
keyPath := filepath.Join(t.TempDir(), "jwt-rsa-2048.priv")
algorithm := "RS256"
key, err := InitAsymmetricSigningKey(keyPath, algorithm)
require.NoError(t, err)
claimsIn := testClaims{
Foo: "bar",
RegisteredClaims: jwt.RegisteredClaims{},
}
token, err := key.JWT(&claimsIn)
require.NoError(t, err)
var claimsOut testClaims
parsed, err := jwt.ParseWithClaims(token, &claimsOut, func(valToken *jwt.Token) (any, error) {
assert.NotNil(t, valToken.Method)
assert.Equal(t, key.SigningMethod().Alg(), valToken.Method.Alg())
kid, ok := valToken.Header["kid"]
assert.True(t, ok)
assert.NotNil(t, kid)
return key.VerifyKey(), nil
})
require.NoError(t, err)
assert.NotNil(t, parsed)
assert.Equal(t, "bar", parsed.Claims.(*testClaims).Foo)
assert.Equal(t, "bar", claimsOut.Foo)
// dup to keyFunc above
kid, ok := parsed.Header["kid"]
assert.True(t, ok)
assert.NotNil(t, kid)
}
func TestCannotCreatePrivateKey(t *testing.T) {
_, err := InitAsymmetricSigningKey("/dev/directory-does-not-exist-and-you-should-not-have-permission-to-create/privatekey.pem", "RS256")
require.Error(t, err)