feat(openid/config): require and expose the client JWK algorithm

Both the assertion signer and the provider validation derived the algorithm
from the key and had to handle a missing "alg" that NewClientConfig already
rejects. Validate it once at construction and keep the result.
This commit is contained in:
Trong Huu Nguyen
2026-08-10 12:32:04 +02:00
parent f3f76fccc8
commit 2c708d554c
6 changed files with 91 additions and 6 deletions
+1 -1
View File
@@ -34,7 +34,7 @@ The following flags are available:
| `openid.acr-values` | string | | Space separated string that configures the default security level (`acr_values`) parameter for authorization requests. |
| `openid.audiences` | strings | | List of additional trusted audiences (other than the client_id) for OpenID Connect id_token validation. |
| `openid.client-id` | string | | Client ID for the OpenID client. |
| `openid.client-jwk` | string | | JWK containing the private key for the OpenID client in string format. If configured, this takes precedence over `openid.client-secret`. |
| `openid.client-jwk` | string | | JWK containing the private key for the OpenID client in string format. Must declare the `alg` header. If configured, this takes precedence over `openid.client-secret`. |
| `openid.client-secret` | string | | Client secret for the OpenID client. Overridden by `openid.client-jwk`, if configured. |
| `openid.domain-hint` | string | | Domain hint to include in authorization request for IdPs that support this parameter (e.g. Entra ID). |
| `openid.jwks-fallback-alg` | string | `RS256` | JWA value (as defined in RFC 7518) to assign to provider JWKS keys when their `alg` header is not set. |
+1 -1
View File
@@ -75,7 +75,7 @@ func openidFlags() {
flag.String(OpenIDACRValues, "", "Space separated string that configures the default security level (acr_values) parameter for authorization requests.")
flag.StringSlice(OpenIDAudiences, []string{}, "List of additional trusted audiences (other than the client_id) for OpenID Connect id_token validation.")
flag.String(OpenIDClientID, "", "Client ID for the OpenID client.")
flag.String(OpenIDClientJWK, "", "JWK containing the private key for the OpenID client in string format. If configured, this takes precedence over 'openid.client-secret'.")
flag.String(OpenIDClientJWK, "", "JWK containing the private key for the OpenID client in string format. Must declare the 'alg' header. If configured, this takes precedence over 'openid.client-secret'.")
flag.String(OpenIDClientSecret, "", "Client secret for the OpenID client. Overridden by 'openid.client-jwk', if configured.")
flag.String(OpenIDDomainHint, "", "Domain hint to include in authorization request for IdPs that support this parameter (e.g. Entra ID).")
flag.String(OpenIDJWKSFallbackAlg, jwa.RS256().String(), "JWA value (as defined in RFC 7518) to assign to provider JWKS keys when their 'alg' header is not set.")
+12
View File
@@ -1,6 +1,7 @@
package mock
import (
"github.com/lestrrat-go/jwx/v3/jwa"
"github.com/lestrrat-go/jwx/v3/jwk"
"github.com/nais/wonderwall/internal/crypto"
"github.com/nais/wonderwall/pkg/config"
@@ -11,6 +12,7 @@ import (
type TestClientConfiguration struct {
*config.Config
clientJwk jwk.Key
clientJwkAlg jwa.KeyAlgorithm
trustedAudiences map[string]bool
}
@@ -36,6 +38,10 @@ func (c *TestClientConfiguration) ClientJWK() jwk.Key {
return c.clientJwk
}
func (c *TestClientConfiguration) ClientJWKAlgorithm() jwa.KeyAlgorithm {
return c.clientJwkAlg
}
func (c *TestClientConfiguration) ClientSecret() string {
return c.OpenID.ClientSecret
}
@@ -78,9 +84,15 @@ func clientConfiguration(cfg *config.Config) *TestClientConfiguration {
panic(err)
}
alg, ok := key.Algorithm()
if !ok {
panic("test client JWK is missing an algorithm")
}
return &TestClientConfiguration{
Config: cfg,
clientJwk: key,
clientJwkAlg: alg,
trustedAudiences: cfg.OpenID.TrustedAudiences(),
}
}
+1 -4
View File
@@ -184,10 +184,7 @@ func (c *Client) ClientAuthenticationAssertion(expiration time.Duration) (string
return "", fmt.Errorf("building client assertion: %w", err)
}
alg, ok := key.Algorithm()
if !ok {
return "", fmt.Errorf("missing algorithm on client key")
}
alg := clientCfg.ClientJWKAlgorithm()
opts := make([]jwt.Option, 0)
if c.cfg.Client().NewClientAuthJWTType() {
+14
View File
@@ -3,6 +3,7 @@ package config
import (
"fmt"
"github.com/lestrrat-go/jwx/v3/jwa"
"github.com/lestrrat-go/jwx/v3/jwk"
log "github.com/sirupsen/logrus"
@@ -23,6 +24,7 @@ type Client interface {
AuthMethod() AuthMethod
ClientID() string
ClientJWK() jwk.Key
ClientJWKAlgorithm() jwa.KeyAlgorithm
ClientSecret() string
DomainHint() string
NewClientAuthJWTType() bool
@@ -37,6 +39,7 @@ type client struct {
config.OpenID
authMethod AuthMethod
clientJwk jwk.Key
clientJwkAlg jwa.KeyAlgorithm
trustedAudiences map[string]bool
}
@@ -62,6 +65,12 @@ func (in *client) ClientJWK() jwk.Key {
return in.clientJwk
}
// ClientJWKAlgorithm returns the algorithm declared by the client JWK, or nil
// when authenticating with a client secret. NewClientConfig guarantees it is set.
func (in *client) ClientJWKAlgorithm() jwa.KeyAlgorithm {
return in.clientJwkAlg
}
func (in *client) ClientSecret() string {
return in.OpenID.ClientSecret
}
@@ -117,8 +126,13 @@ func NewClientConfig(cfg *config.Config) (Client, error) {
if err != nil {
return nil, fmt.Errorf("parsing client JWK: %w", err)
}
alg, ok := clientJwk.Algorithm()
if !ok {
return nil, fmt.Errorf("client JWK is missing required %q", jwk.AlgorithmKey)
}
c.clientJwk = clientJwk
c.clientJwkAlg = alg
c.authMethod = AuthMethodPrivateKeyJWT
}
+62
View File
@@ -0,0 +1,62 @@
package config_test
import (
"encoding/json"
"testing"
"github.com/lestrrat-go/jwx/v3/jwa"
"github.com/nais/wonderwall/internal/crypto"
"github.com/nais/wonderwall/pkg/config"
openidconfig "github.com/nais/wonderwall/pkg/openid/config"
"github.com/stretchr/testify/require"
)
func TestNewClientConfigRequiresClientJWKAlgorithm(t *testing.T) {
key, err := crypto.NewJwk()
require.NoError(t, err)
rawKey, err := json.Marshal(key)
require.NoError(t, err)
var keyFields map[string]any
require.NoError(t, json.Unmarshal(rawKey, &keyFields))
for _, tt := range []struct {
name string
removeAlg bool
wantConfig bool
}{
{name: "algorithm is present", wantConfig: true},
{name: "algorithm is missing", removeAlg: true},
} {
t.Run(tt.name, func(t *testing.T) {
fields := make(map[string]any, len(keyFields))
for name, value := range keyFields {
fields[name] = value
}
if tt.removeAlg {
delete(fields, "alg")
}
clientJWK, err := json.Marshal(fields)
require.NoError(t, err)
cfg := &config.Config{OpenID: config.OpenID{
ClientID: "client-id",
ClientJWK: string(clientJWK),
Provider: config.ProviderOpenID,
WellKnownURL: "https://issuer.example/.well-known/openid-configuration",
}}
client, err := openidconfig.NewClientConfig(cfg)
if tt.wantConfig {
require.NoError(t, err)
require.NotNil(t, client)
require.Equal(t, jwa.RS256(), client.ClientJWKAlgorithm())
return
}
require.ErrorContains(t, err, "client JWK is missing required")
})
}
}