mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-08-31 08:11:27 +02:00
feat: add explicit public keys for federated client credentials
A federated client credential could only point at a JWKS URL, which requires the assertion issuer to host a discoverable JWKS. This PR allows an admin to manually import public keys for FIC, so Pocket ID works better in air-gapped environments. Also fixes an error introduced by #1679 where some text labels were not added to paraglide
This commit is contained in:
@@ -1,6 +1,10 @@
|
||||
package dto
|
||||
|
||||
import datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
)
|
||||
|
||||
type OidcClientMetaDataDto struct {
|
||||
ID string `json:"id"`
|
||||
@@ -95,11 +99,12 @@ type OidcClientCredentialsDto struct {
|
||||
}
|
||||
|
||||
type OidcClientFederatedIdentityDto struct {
|
||||
Issuer string `json:"issuer"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
Audience string `json:"audience,omitempty"`
|
||||
JWKS string `json:"jwks,omitempty"`
|
||||
ReplayProtection bool `json:"replayProtection"`
|
||||
Issuer string `json:"issuer"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
Audience string `json:"audience,omitempty"`
|
||||
JWKS string `json:"jwks,omitempty"`
|
||||
PublicKeys []json.RawMessage `json:"publicKeys,omitempty"`
|
||||
ReplayProtection bool `json:"replayProtection"`
|
||||
}
|
||||
|
||||
type OidcUpdateAllowedUserGroupsDto struct {
|
||||
|
||||
@@ -157,11 +157,12 @@ func (occ OidcClientCredentials) ActiveSecrets() []OidcClientSecret {
|
||||
}
|
||||
|
||||
type OidcClientFederatedIdentity struct {
|
||||
Issuer string `json:"issuer"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
Audience string `json:"audience,omitempty"`
|
||||
JWKS string `json:"jwks,omitempty"` // URL of the JWKS
|
||||
ReplayProtection bool `json:"replayProtection,omitempty"`
|
||||
Issuer string `json:"issuer"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
Audience string `json:"audience,omitempty"`
|
||||
JWKS string `json:"jwks,omitempty"` // URL of the JWKS - mutually exclusive with PublicKeys
|
||||
PublicKeys []json.RawMessage `json:"publicKeys,omitempty"` // Raw JWKs - mutually exclusive with JWKS
|
||||
ReplayProtection bool `json:"replayProtection,omitempty"`
|
||||
}
|
||||
|
||||
func (occ OidcClientCredentials) FederatedIdentityForIssuer(issuer string) (OidcClientFederatedIdentity, bool) {
|
||||
|
||||
@@ -17,6 +17,9 @@ import (
|
||||
"github.com/lestrrat-go/jwx/v3/jws"
|
||||
"github.com/lestrrat-go/jwx/v3/jwt"
|
||||
"github.com/ory/fosite"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk"
|
||||
)
|
||||
|
||||
const clientAssertionTypeJWTBearer = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" // #nosec G101 -- OAuth assertion type identifier, not a credential
|
||||
@@ -143,14 +146,9 @@ func (a *federatedClientAuthenticator) authenticateAssertion(ctx context.Context
|
||||
return nil, errNoFederatedClientAssertion
|
||||
}
|
||||
|
||||
jwksURL := federatedIdentity.JWKS
|
||||
if jwksURL == "" {
|
||||
jwksURL = strings.TrimRight(issuer, "/") + "/.well-known/jwks.json"
|
||||
}
|
||||
|
||||
jwks, err := a.fetchJWKSet(ctx, jwksURL)
|
||||
jwks, err := a.keySetForIdentity(ctx, federatedIdentity)
|
||||
if err != nil {
|
||||
return nil, fosite.ErrInvalidClient.WithHint("Unable to fetch client assertion JWKS.").WithWrap(err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
audience := federatedIdentity.Audience
|
||||
@@ -196,6 +194,30 @@ func (a *federatedClientAuthenticator) authenticateAssertion(ctx context.Context
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// keySetForIdentity returns the keys that may have signed an assertion for the given identity.
|
||||
// Identities with public keys configured are verified against those alone, so no JWKS is fetched over the network.
|
||||
func (a *federatedClientAuthenticator) keySetForIdentity(ctx context.Context, federatedIdentity model.OidcClientFederatedIdentity) (jwk.Set, error) {
|
||||
if len(federatedIdentity.PublicKeys) > 0 {
|
||||
jwks, err := jwkutils.ParsePublicKeySet(federatedIdentity.PublicKeys)
|
||||
if err != nil {
|
||||
return nil, fosite.ErrInvalidClient.WithHint("Unable to load the public keys configured for the client assertion.").WithWrap(err)
|
||||
}
|
||||
return jwks, nil
|
||||
}
|
||||
|
||||
jwksURL := federatedIdentity.JWKS
|
||||
if jwksURL == "" {
|
||||
jwksURL = strings.TrimRight(federatedIdentity.Issuer, "/") + "/.well-known/jwks.json"
|
||||
}
|
||||
|
||||
jwks, err := a.fetchJWKSet(ctx, jwksURL)
|
||||
if err != nil {
|
||||
return nil, fosite.ErrInvalidClient.WithHint("Unable to fetch client assertion JWKS.").WithWrap(err)
|
||||
}
|
||||
|
||||
return jwks, nil
|
||||
}
|
||||
|
||||
func (a *federatedClientAuthenticator) fetchJWKSet(ctx context.Context, jwksURL string) (jwk.Set, error) {
|
||||
if !a.jwksCache.IsRegistered(ctx, jwksURL) {
|
||||
// We set a timeout because otherwise Register will keep trying in case of errors
|
||||
|
||||
@@ -308,3 +308,114 @@ func TestFederatedClientAuthenticatorCachesJWKS(t *testing.T) {
|
||||
require.Equal(t, clientID, client.GetID())
|
||||
require.EqualValues(t, 1, requests.Load())
|
||||
}
|
||||
|
||||
func TestFederatedClientAuthenticatorConfiguredPublicKeys(t *testing.T) {
|
||||
const (
|
||||
issuer = "https://agent.example.com"
|
||||
clientID = "federated-client"
|
||||
audience = "https://pocket-id.example.com"
|
||||
)
|
||||
|
||||
generateKeyPair := func(t *testing.T) (jwk.Key, json.RawMessage) {
|
||||
t.Helper()
|
||||
|
||||
signingKey, err := jwkutils.GenerateKey(jwa.ES256().String(), "")
|
||||
require.NoError(t, err)
|
||||
publicKey, err := signingKey.PublicKey()
|
||||
require.NoError(t, err)
|
||||
encoded, err := json.Marshal(publicKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
return signingKey, encoded
|
||||
}
|
||||
|
||||
// The JWKS endpoint must never be called when public keys are configured
|
||||
failingHTTPClient := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return nil, errors.New("no JWKS request is expected")
|
||||
})}
|
||||
|
||||
newAuthenticator := func(t *testing.T, identity model.OidcClientFederatedIdentity) *federatedClientAuthenticator {
|
||||
t.Helper()
|
||||
|
||||
store := &fakeFederatedStore{
|
||||
client: Client{OidcClient: model.OidcClient{
|
||||
Base: model.Base{ID: clientID},
|
||||
Name: "Federated Client",
|
||||
Credentials: model.OidcClientCredentials{FederatedIdentities: []model.OidcClientFederatedIdentity{identity}},
|
||||
}},
|
||||
jtis: map[string]time.Time{},
|
||||
}
|
||||
authenticator, err := newFederatedClientAuthenticator(t.Context(), store, failingHTTPClient, audience)
|
||||
require.NoError(t, err)
|
||||
return authenticator
|
||||
}
|
||||
|
||||
signAssertion := func(t *testing.T, signingKey jwk.Key) string {
|
||||
t.Helper()
|
||||
|
||||
token, err := jwt.NewBuilder().
|
||||
Issuer(issuer).
|
||||
Subject(clientID).
|
||||
Audience([]string{audience}).
|
||||
IssuedAt(time.Now()).
|
||||
Expiration(time.Now().Add(5 * time.Minute)).
|
||||
Build()
|
||||
require.NoError(t, err)
|
||||
|
||||
alg, ok := signingKey.Algorithm()
|
||||
require.True(t, ok)
|
||||
signed, err := jwt.Sign(token, jwt.WithKey(alg, signingKey))
|
||||
require.NoError(t, err)
|
||||
return string(signed)
|
||||
}
|
||||
|
||||
t.Run("authenticates with a configured public key", func(t *testing.T) {
|
||||
signingKey, publicKey := generateKeyPair(t)
|
||||
authenticator := newAuthenticator(t, model.OidcClientFederatedIdentity{
|
||||
Issuer: issuer,
|
||||
PublicKeys: []json.RawMessage{publicKey},
|
||||
})
|
||||
|
||||
client, err := authenticator.authenticateAssertion(t.Context(), signAssertion(t, signingKey), clientID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, clientID, client.GetID())
|
||||
})
|
||||
|
||||
t.Run("selects the key matching the assertion", func(t *testing.T) {
|
||||
_, firstPublicKey := generateKeyPair(t)
|
||||
secondSigningKey, secondPublicKey := generateKeyPair(t)
|
||||
authenticator := newAuthenticator(t, model.OidcClientFederatedIdentity{
|
||||
Issuer: issuer,
|
||||
PublicKeys: []json.RawMessage{firstPublicKey, secondPublicKey},
|
||||
})
|
||||
|
||||
client, err := authenticator.authenticateAssertion(t.Context(), signAssertion(t, secondSigningKey), clientID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, clientID, client.GetID())
|
||||
})
|
||||
|
||||
t.Run("rejects an assertion signed by an unknown key", func(t *testing.T) {
|
||||
_, publicKey := generateKeyPair(t)
|
||||
otherSigningKey, _ := generateKeyPair(t)
|
||||
authenticator := newAuthenticator(t, model.OidcClientFederatedIdentity{
|
||||
Issuer: issuer,
|
||||
PublicKeys: []json.RawMessage{publicKey},
|
||||
})
|
||||
|
||||
_, err := authenticator.authenticateAssertion(t.Context(), signAssertion(t, otherSigningKey), clientID)
|
||||
require.ErrorIs(t, err, fosite.ErrInvalidClient)
|
||||
})
|
||||
|
||||
t.Run("configured public keys take precedence over a JWKS URL", func(t *testing.T) {
|
||||
signingKey, publicKey := generateKeyPair(t)
|
||||
authenticator := newAuthenticator(t, model.OidcClientFederatedIdentity{
|
||||
Issuer: issuer,
|
||||
JWKS: "https://agent.example.com/jwks.json",
|
||||
PublicKeys: []json.RawMessage{publicKey},
|
||||
})
|
||||
|
||||
client, err := authenticator.authenticateAssertion(t.Context(), signAssertion(t, signingKey), clientID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, clientID, client.GetID())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import (
|
||||
"github.com/pocket-id/pocket-id/backend/internal/storage"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
imageutil "github.com/pocket-id/pocket-id/backend/internal/utils/image"
|
||||
jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -152,9 +153,12 @@ func (s *OidcService) CreateClient(ctx context.Context, input dto.OidcClientCrea
|
||||
},
|
||||
CreatedByID: new(userID),
|
||||
}
|
||||
updateOIDCClientModelFromDto(&client, &input.OidcClientUpdateDto)
|
||||
err := updateOIDCClientModelFromDto(&client, &input.OidcClientUpdateDto)
|
||||
if err != nil {
|
||||
return model.OidcClient{}, err
|
||||
}
|
||||
|
||||
err := s.db.
|
||||
err = s.db.
|
||||
WithContext(ctx).
|
||||
Create(&client).
|
||||
Error
|
||||
@@ -194,7 +198,10 @@ func (s *OidcService) UpdateClient(ctx context.Context, clientID string, input d
|
||||
return model.OidcClient{}, err
|
||||
}
|
||||
|
||||
updateOIDCClientModelFromDto(&client, &input)
|
||||
err = updateOIDCClientModelFromDto(&client, &input)
|
||||
if err != nil {
|
||||
return model.OidcClient{}, err
|
||||
}
|
||||
|
||||
if !input.IsGroupRestricted {
|
||||
// Clear allowed user groups if the restriction is removed
|
||||
@@ -249,7 +256,7 @@ func (s *OidcService) UpdateClient(ctx context.Context, clientID string, input d
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func updateOIDCClientModelFromDto(client *model.OidcClient, input *dto.OidcClientUpdateDto) {
|
||||
func updateOIDCClientModelFromDto(client *model.OidcClient, input *dto.OidcClientUpdateDto) error {
|
||||
// Update fields that remain locally managed for every client type
|
||||
client.Description = input.Description
|
||||
client.RequiresReauthentication = input.RequiresReauthentication
|
||||
@@ -264,7 +271,7 @@ func updateOIDCClientModelFromDto(client *model.OidcClient, input *dto.OidcClien
|
||||
|
||||
// Preserve fields that are sourced from the client metadata document
|
||||
if client.IsMetadataDocument() {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update registration fields for manually configured clients
|
||||
@@ -280,17 +287,29 @@ func updateOIDCClientModelFromDto(client *model.OidcClient, input *dto.OidcClien
|
||||
}
|
||||
|
||||
// Replace the federated credentials with the submitted configuration
|
||||
client.Credentials.FederatedIdentities = make([]model.OidcClientFederatedIdentity, len(input.Credentials.FederatedIdentities))
|
||||
federatedIdentities := make([]model.OidcClientFederatedIdentity, len(input.Credentials.FederatedIdentities))
|
||||
for i, fi := range input.Credentials.FederatedIdentities {
|
||||
client.Credentials.FederatedIdentities[i] = model.OidcClientFederatedIdentity{
|
||||
// Validate the public keys before storing them
|
||||
publicKeys, err := jwkutils.NormalizePublicKeys(fi.PublicKeys)
|
||||
if err != nil {
|
||||
return apperror.ValidationMessage(fmt.Sprintf("Federated client credential %d has an invalid public key: %v", i+1, err))
|
||||
}
|
||||
if len(publicKeys) > 0 && fi.JWKS != "" {
|
||||
return apperror.ValidationMessage(fmt.Sprintf("Federated client credential %d must use either a JWKS URL or public keys, but not both", i+1))
|
||||
}
|
||||
|
||||
federatedIdentities[i] = model.OidcClientFederatedIdentity{
|
||||
Issuer: fi.Issuer,
|
||||
Audience: fi.Audience,
|
||||
Subject: fi.Subject,
|
||||
JWKS: fi.JWKS,
|
||||
PublicKeys: publicKeys,
|
||||
ReplayProtection: fi.ReplayProtection,
|
||||
}
|
||||
}
|
||||
client.Credentials.FederatedIdentities = federatedIdentities
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *OidcService) DeleteClient(ctx context.Context, clientID string) error {
|
||||
|
||||
101
backend/internal/utils/jwk/public_key.go
Normal file
101
backend/internal/utils/jwk/public_key.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package jwk
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/lestrrat-go/jwx/v3/jwk"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrPublicKeyNotAsymmetric is returned for keys that can't be used to verify a signature from a third party, such as symmetric ones
|
||||
ErrPublicKeyNotAsymmetric = errors.New("key is not an asymmetric public key")
|
||||
// ErrPublicKeyIsPrivate is returned for keys that contain private key material, which must never be uploaded to Pocket ID
|
||||
ErrPublicKeyIsPrivate = errors.New("key contains private key material")
|
||||
// ErrPublicKeyMissingKeyID is returned for keys without a "kid" property
|
||||
ErrPublicKeyMissingKeyID = errors.New(`key is missing the "kid" property`)
|
||||
// ErrPublicKeyNotForSigning is returned for keys whose "use" property restricts them to something other than verifying signatures
|
||||
ErrPublicKeyNotForSigning = errors.New(`key is not meant to verify signatures, its "use" is not "sig"`)
|
||||
)
|
||||
|
||||
// ParsePublicKey parses a single JWK that is trusted to verify signatures, such as one of the public keys configured on a federated client credential.
|
||||
func ParsePublicKey(raw []byte) (jwk.Key, error) {
|
||||
key, err := jwk.ParseKey(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse key: %w", err)
|
||||
}
|
||||
|
||||
// Keys must be asymmetric and not have the private part
|
||||
isPrivate, err := jwk.IsPrivateKey(key)
|
||||
if err != nil {
|
||||
// An error indicates the key isn't asymmetric at all
|
||||
return nil, ErrPublicKeyNotAsymmetric
|
||||
} else if isPrivate {
|
||||
return nil, ErrPublicKeyIsPrivate
|
||||
}
|
||||
|
||||
// Keys must have a "kid", which is required by Pocket ID to select the correct signing key
|
||||
kid, ok := key.KeyID()
|
||||
if !ok || kid == "" {
|
||||
return nil, ErrPublicKeyMissingKeyID
|
||||
}
|
||||
|
||||
// A key restricted to encryption can never verify a signature
|
||||
use, ok := key.KeyUsage()
|
||||
if ok && use != "" && use != KeyUsageSigning {
|
||||
return nil, ErrPublicKeyNotForSigning
|
||||
}
|
||||
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// ParsePublicKeySet parses a list of JWKs into a key set, validating each key with ParsePublicKey.
|
||||
func ParsePublicKeySet(raw []json.RawMessage) (jwk.Set, error) {
|
||||
set := jwk.NewSet()
|
||||
|
||||
for i, rawKey := range raw {
|
||||
key, err := ParsePublicKey(rawKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("key %d is invalid: %w", i+1, err)
|
||||
}
|
||||
|
||||
// Key IDs must be unique within the set
|
||||
kid, _ := key.KeyID()
|
||||
_, ok := set.LookupKeyID(kid)
|
||||
if ok {
|
||||
return nil, fmt.Errorf("key %d has the same key ID %q as an earlier key", i+1, kid)
|
||||
}
|
||||
|
||||
err = set.AddKey(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to add key %d to the key set: %w", i+1, err)
|
||||
}
|
||||
}
|
||||
|
||||
return set, nil
|
||||
}
|
||||
|
||||
// NormalizePublicKeys validates a list of JWKs with ParsePublicKeySet and returns them re-encoded, so only well-formed keys are ever persisted.
|
||||
func NormalizePublicKeys(raw []json.RawMessage) ([]json.RawMessage, error) {
|
||||
set, err := ParsePublicKeySet(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if set.Len() == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
normalized := make([]json.RawMessage, set.Len())
|
||||
for i := range set.Len() {
|
||||
key, _ := set.Key(i)
|
||||
encoded, err := EncodeJWKBytes(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encode key %d: %w", i+1, err)
|
||||
}
|
||||
normalized[i] = json.RawMessage(bytes.TrimSpace(encoded))
|
||||
}
|
||||
|
||||
return normalized, nil
|
||||
}
|
||||
154
backend/internal/utils/jwk/public_key_test.go
Normal file
154
backend/internal/utils/jwk/public_key_test.go
Normal file
@@ -0,0 +1,154 @@
|
||||
package jwk
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/lestrrat-go/jwx/v3/jwa"
|
||||
"github.com/lestrrat-go/jwx/v3/jwk"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// generateTestPublicKey returns a public JWK, encoded as it would be pasted in the admin UI
|
||||
func generateTestPublicKey(t *testing.T, alg string) (jwk.Key, json.RawMessage) {
|
||||
t.Helper()
|
||||
|
||||
privateKey, err := GenerateKey(alg, "")
|
||||
require.NoError(t, err)
|
||||
publicKey, err := privateKey.PublicKey()
|
||||
require.NoError(t, err)
|
||||
|
||||
encoded, err := json.Marshal(publicKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
return publicKey, encoded
|
||||
}
|
||||
|
||||
func TestParsePublicKey(t *testing.T) {
|
||||
t.Run("parses a public key", func(t *testing.T) {
|
||||
publicKey, encoded := generateTestPublicKey(t, jwa.RS256().String())
|
||||
|
||||
parsed, err := ParsePublicKey(encoded)
|
||||
require.NoError(t, err)
|
||||
|
||||
expectedKid, _ := publicKey.KeyID()
|
||||
parsedKid, ok := parsed.KeyID()
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, expectedKid, parsedKid)
|
||||
})
|
||||
|
||||
t.Run("rejects invalid JSON", func(t *testing.T) {
|
||||
_, err := ParsePublicKey([]byte("not-a-jwk"))
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("rejects keys with private key material", func(t *testing.T) {
|
||||
privateKey, err := GenerateKey(jwa.ES256().String(), "")
|
||||
require.NoError(t, err)
|
||||
encoded, err := json.Marshal(privateKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = ParsePublicKey(encoded)
|
||||
require.ErrorIs(t, err, ErrPublicKeyIsPrivate)
|
||||
})
|
||||
|
||||
t.Run("rejects symmetric keys", func(t *testing.T) {
|
||||
symmetricKey, err := jwk.Import([]byte("this-is-a-shared-secret"))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, symmetricKey.Set(jwk.KeyIDKey, "symmetric"))
|
||||
encoded, err := json.Marshal(symmetricKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = ParsePublicKey(encoded)
|
||||
require.ErrorIs(t, err, ErrPublicKeyNotAsymmetric)
|
||||
})
|
||||
|
||||
t.Run("rejects keys without a key ID", func(t *testing.T) {
|
||||
_, encoded := generateTestPublicKey(t, jwa.RS256().String())
|
||||
|
||||
var key map[string]any
|
||||
require.NoError(t, json.Unmarshal(encoded, &key))
|
||||
delete(key, "kid")
|
||||
withoutKid, err := json.Marshal(key)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = ParsePublicKey(withoutKid)
|
||||
require.ErrorIs(t, err, ErrPublicKeyMissingKeyID)
|
||||
})
|
||||
}
|
||||
|
||||
func TestParsePublicKeySet(t *testing.T) {
|
||||
t.Run("parses multiple keys", func(t *testing.T) {
|
||||
first, encodedFirst := generateTestPublicKey(t, jwa.RS256().String())
|
||||
second, encodedSecond := generateTestPublicKey(t, jwa.ES256().String())
|
||||
|
||||
set, err := ParsePublicKeySet([]json.RawMessage{encodedFirst, encodedSecond})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 2, set.Len())
|
||||
|
||||
for _, key := range []jwk.Key{first, second} {
|
||||
kid, _ := key.KeyID()
|
||||
_, found := set.LookupKeyID(kid)
|
||||
assert.True(t, found, "key %s is missing from the set", kid)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns an empty set for no keys", func(t *testing.T) {
|
||||
set, err := ParsePublicKeySet(nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, set.Len())
|
||||
})
|
||||
|
||||
t.Run("rejects duplicate key IDs", func(t *testing.T) {
|
||||
_, encodedFirst := generateTestPublicKey(t, jwa.RS256().String())
|
||||
_, encodedSecond := generateTestPublicKey(t, jwa.RS256().String())
|
||||
|
||||
var second map[string]any
|
||||
err := json.Unmarshal(encodedSecond, &second)
|
||||
require.NoError(t, err)
|
||||
var first map[string]any
|
||||
err = json.Unmarshal(encodedFirst, &first)
|
||||
require.NoError(t, err)
|
||||
second["kid"] = first["kid"]
|
||||
duplicate, err := json.Marshal(second)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = ParsePublicKeySet([]json.RawMessage{encodedFirst, duplicate})
|
||||
require.ErrorContains(t, err, "same key ID")
|
||||
})
|
||||
|
||||
t.Run("reports the position of the invalid key", func(t *testing.T) {
|
||||
_, encoded := generateTestPublicKey(t, jwa.RS256().String())
|
||||
|
||||
_, err := ParsePublicKeySet([]json.RawMessage{encoded, []byte(`{"kty":"RSA"}`)})
|
||||
require.ErrorContains(t, err, "key 2 is invalid")
|
||||
})
|
||||
}
|
||||
|
||||
func TestNormalizePublicKeys(t *testing.T) {
|
||||
t.Run("re-encodes keys", func(t *testing.T) {
|
||||
publicKey, encoded := generateTestPublicKey(t, jwa.RS256().String())
|
||||
|
||||
normalized, err := NormalizePublicKeys([]json.RawMessage{json.RawMessage(" " + string(encoded) + "\n")})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, normalized, 1)
|
||||
|
||||
parsed, err := jwk.ParseKey(normalized[0])
|
||||
require.NoError(t, err)
|
||||
expectedKid, _ := publicKey.KeyID()
|
||||
parsedKid, _ := parsed.KeyID()
|
||||
assert.Equal(t, expectedKid, parsedKid)
|
||||
})
|
||||
|
||||
t.Run("returns nil for no keys", func(t *testing.T) {
|
||||
normalized, err := NormalizePublicKeys(nil)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, normalized)
|
||||
})
|
||||
|
||||
t.Run("returns an error for an invalid key", func(t *testing.T) {
|
||||
_, err := NormalizePublicKeys([]json.RawMessage{[]byte(`{"kty":"oct","k":"c2VjcmV0","kid":"shared"}`)})
|
||||
require.ErrorIs(t, err, ErrPublicKeyNotAsymmetric)
|
||||
})
|
||||
}
|
||||
@@ -398,6 +398,32 @@
|
||||
"federated_client_credentials_description": "Federated client credentials allow authenticating OIDC clients without managing long-lived secrets. They leverage JWT tokens issued by third-party authorities for client assertions, e.g. workload identity tokens.",
|
||||
"add_federated_client_credential": "Add Federated Client Credential",
|
||||
"add_another_federated_client_credential": "Add another federated client credential",
|
||||
"federated_identity_number": "Identity {number}",
|
||||
"remove_federated_identity": "Remove federated identity",
|
||||
"issuer": "Issuer",
|
||||
"subject": "Subject",
|
||||
"defaults_to_the_client_id": "Defaults to the client ID",
|
||||
"audience": "Audience",
|
||||
"defaults_to_the_appname_url": "Defaults to the {appName} URL",
|
||||
"signing_keys": "Signing keys",
|
||||
"jwks_url": "JWKS URL",
|
||||
"defaults_to_the_issuer_jwks_url": "Defaults to {issuer}/.well-known/jwks.json",
|
||||
"public_keys": "Public keys",
|
||||
"public_key": "Public key",
|
||||
"add_public_key": "Add public key",
|
||||
"remove_public_key": "Remove public key {keyId}",
|
||||
"paste_public_key_description": "Paste a public key in JWK format, or a JWKS for multiple keys. Every key must have a key ID.",
|
||||
"public_key_already_added": "A key with the key ID \"{keyId}\" has already been added",
|
||||
"paste_a_public_key_or_a_jwks": "Paste a public key in JWK format, or a JWKS containing multiple keys",
|
||||
"the_value_is_not_valid_json": "The value is not valid JSON",
|
||||
"the_value_is_not_a_jwk_or_a_jwks": "The value is not a JWK or a JWKS",
|
||||
"the_jwks_does_not_contain_any_key": "The JWKS does not contain any key",
|
||||
"jwks_key_is_not_a_jwk": "Key {number} of the JWKS is not a JWK",
|
||||
"jwks_key_is_invalid": "Key {number} of the JWKS is invalid: {error}",
|
||||
"the_key_is_missing_the_property": "The key is missing the \"{property}\" property",
|
||||
"keys_of_this_type_cannot_verify_signatures": "Keys of type \"{keyType}\" can't be used to verify signatures",
|
||||
"the_key_contains_private_key_material": "The key contains private key material (the \"{property}\" property), only public keys may be configured",
|
||||
"the_key_is_not_meant_to_verify_signatures": "The key is meant to be used for \"{use}\", not to verify signatures",
|
||||
"oidc_allowed_group_count": "Allowed Group Count",
|
||||
"show_advanced_options": "Show Advanced Options",
|
||||
"hide_advanced_options": "Hide Advanced Options",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Jwk } from '$lib/utils/jwk-util';
|
||||
import type { UserGroup } from './user-group.type';
|
||||
|
||||
export type OidcClientType = 'standard' | 'cimd';
|
||||
@@ -18,6 +19,7 @@ export type OidcClientFederatedIdentity = {
|
||||
subject?: string;
|
||||
audience?: string;
|
||||
jwks?: string | undefined;
|
||||
publicKeys?: Jwk[];
|
||||
replayProtection: boolean;
|
||||
};
|
||||
|
||||
|
||||
125
frontend/src/lib/utils/jwk-util.ts
Normal file
125
frontend/src/lib/utils/jwk-util.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
|
||||
export type Jwk = {
|
||||
kty?: unknown;
|
||||
kid?: unknown;
|
||||
alg?: unknown;
|
||||
use?: unknown;
|
||||
crv?: unknown;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
// Members that only exist on private keys, which must never be sent to Pocket ID
|
||||
const privateKeyMembers = ['d', 'p', 'q', 'dp', 'dq', 'qi', 'oth', 'k'];
|
||||
|
||||
// Members that a public key must have, by key type
|
||||
// This is excluding symmetric (OKP) keys
|
||||
const requiredMembers: Record<string, string[]> = {
|
||||
RSA: ['n', 'e'],
|
||||
EC: ['crv', 'x', 'y'],
|
||||
OKP: ['crv', 'x']
|
||||
};
|
||||
|
||||
export type ParseJwkResult = { ok: true; keys: Jwk[] } | { ok: false; error: string };
|
||||
|
||||
/**
|
||||
* Parses pasted JWK input into a list of keys.
|
||||
* The input can either be a single JWK or a JWKS, in which case each key it contains is imported separately.
|
||||
*/
|
||||
export function parseJwkInput(input: string): ParseJwkResult {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) {
|
||||
return { ok: false, error: m.paste_a_public_key_or_a_jwks() };
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(trimmed);
|
||||
} catch {
|
||||
return { ok: false, error: m.the_value_is_not_valid_json() };
|
||||
}
|
||||
|
||||
if (!isJsonObject(parsed)) {
|
||||
return { ok: false, error: m.the_value_is_not_a_jwk_or_a_jwks() };
|
||||
}
|
||||
|
||||
// A JWKS holds its keys in a "keys" array, everything else is treated as a single key
|
||||
if (!('keys' in parsed)) {
|
||||
const error = validateJwk(parsed);
|
||||
return error ? { ok: false, error } : { ok: true, keys: [parsed] };
|
||||
}
|
||||
|
||||
if (!Array.isArray(parsed.keys) || parsed.keys.length === 0) {
|
||||
return { ok: false, error: m.the_jwks_does_not_contain_any_key() };
|
||||
}
|
||||
|
||||
const keys: Jwk[] = [];
|
||||
for (const [index, key] of parsed.keys.entries()) {
|
||||
const number = index + 1;
|
||||
if (!isJsonObject(key)) {
|
||||
return { ok: false, error: m.jwks_key_is_not_a_jwk({ number }) };
|
||||
}
|
||||
|
||||
const error = validateJwk(key);
|
||||
if (error) {
|
||||
return { ok: false, error: m.jwks_key_is_invalid({ number, error }) };
|
||||
}
|
||||
keys.push(key);
|
||||
}
|
||||
|
||||
return { ok: true, keys };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a JWK is a public key Pocket ID can verify signatures with.
|
||||
* Returns an error message, or null when the key is valid.
|
||||
*/
|
||||
export function validateJwk(key: Jwk): string | null {
|
||||
if (typeof key.kty !== 'string' || !key.kty) {
|
||||
return m.the_key_is_missing_the_property({ property: 'kty' });
|
||||
}
|
||||
|
||||
const required = requiredMembers[key.kty];
|
||||
if (!required) {
|
||||
return m.keys_of_this_type_cannot_verify_signatures({ keyType: key.kty });
|
||||
}
|
||||
|
||||
const privateMember = privateKeyMembers.find((member) => member in key);
|
||||
if (privateMember) {
|
||||
return m.the_key_contains_private_key_material({ property: privateMember });
|
||||
}
|
||||
|
||||
const missing = required.find((member) => typeof key[member] !== 'string' || !key[member]);
|
||||
if (missing) {
|
||||
return m.the_key_is_missing_the_property({ property: missing });
|
||||
}
|
||||
|
||||
// Pocket ID selects the key to verify an assertion with by its key ID, so it is always required
|
||||
if (typeof key.kid !== 'string' || !key.kid) {
|
||||
return m.the_key_is_missing_the_property({ property: 'kid' });
|
||||
}
|
||||
|
||||
// A key restricted to encryption can never verify a signature
|
||||
if (typeof key.use === 'string' && key.use && key.use !== 'sig') {
|
||||
return m.the_key_is_not_meant_to_verify_signatures({ use: key.use });
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a short description of a key, such as "RSA · RS256", to show next to its key ID.
|
||||
*/
|
||||
export function describeJwk(key: Jwk): string {
|
||||
return [key.kty, key.crv, key.alg]
|
||||
.filter((part): part is string => typeof part === 'string' && !!part)
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
export function getJwkKeyId(key: Jwk): string {
|
||||
return typeof key.kid === 'string' ? key.kid : '';
|
||||
}
|
||||
|
||||
function isJsonObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -28,6 +28,7 @@
|
||||
subject: z.string().optional(),
|
||||
audience: z.string().optional(),
|
||||
jwks: z.url().optional().or(z.literal('')),
|
||||
publicKeys: z.array(z.record(z.string(), z.unknown())).optional(),
|
||||
replayProtection: z.boolean().default(true)
|
||||
})
|
||||
)
|
||||
@@ -60,6 +61,7 @@
|
||||
subject: '',
|
||||
audience: '',
|
||||
jwks: '',
|
||||
publicKeys: [],
|
||||
replayProtection: true
|
||||
}
|
||||
];
|
||||
|
||||
@@ -4,12 +4,19 @@
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Field from '$lib/components/ui/field';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import * as RadioGroup from '$lib/components/ui/radio-group';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import appConfigStore from '$lib/stores/application-configuration-store';
|
||||
import type { OidcClientFederatedIdentity } from '$lib/types/oidc.type';
|
||||
import { LucideMinus, LucidePlus } from '@lucide/svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import { z } from 'zod/v4';
|
||||
import FederatedIdentityKeysInput from './federated-identity-keys-input.svelte';
|
||||
|
||||
// An identity is verified either against the keys of a JWKS endpoint, or against the public keys configured here
|
||||
type KeySource = 'jwks' | 'publicKeys';
|
||||
|
||||
let {
|
||||
federatedIdentities = $bindable([]),
|
||||
@@ -23,6 +30,13 @@
|
||||
children?: Snippet;
|
||||
} = $props();
|
||||
|
||||
// The source can't be derived from the identity alone: it stays on "Public keys" while no key has been added yet
|
||||
let keySources = $state<KeySource[]>([]);
|
||||
|
||||
function keySourceFor(index: number, identity: OidcClientFederatedIdentity): KeySource {
|
||||
return keySources[index] ?? (identity.publicKeys?.length ? 'publicKeys' : 'jwks');
|
||||
}
|
||||
|
||||
function addFederatedIdentity() {
|
||||
federatedIdentities = [
|
||||
...federatedIdentities,
|
||||
@@ -31,6 +45,7 @@
|
||||
subject: '',
|
||||
audience: '',
|
||||
jwks: '',
|
||||
publicKeys: [],
|
||||
replayProtection: true
|
||||
}
|
||||
];
|
||||
@@ -38,6 +53,7 @@
|
||||
|
||||
function removeFederatedIdentity(index: number) {
|
||||
federatedIdentities = federatedIdentities.filter((_, i) => i !== index);
|
||||
keySources = keySources.filter((_, i) => i !== index);
|
||||
}
|
||||
|
||||
function updateFederatedIdentity<K extends keyof OidcClientFederatedIdentity>(
|
||||
@@ -51,6 +67,19 @@
|
||||
};
|
||||
}
|
||||
|
||||
// Only one of the two sources is ever submitted, so the one that is not selected is cleared
|
||||
function updateKeySource(index: number, source: KeySource) {
|
||||
// The list is rebuilt in full so it stays aligned with the identities when one of them is removed
|
||||
keySources = federatedIdentities.map((identity, i) =>
|
||||
i === index ? source : keySourceFor(i, identity)
|
||||
);
|
||||
if (source === 'jwks') {
|
||||
updateFederatedIdentity(index, 'publicKeys', []);
|
||||
} else {
|
||||
updateFederatedIdentity(index, 'jwks', '');
|
||||
}
|
||||
}
|
||||
|
||||
function getFieldError(index: number, field: keyof OidcClientFederatedIdentity): string | null {
|
||||
if (!errors) return null;
|
||||
const path = [index, field];
|
||||
@@ -61,16 +90,16 @@
|
||||
<div {...restProps}>
|
||||
<FormInput {disabled}>
|
||||
<div class="flex flex-col gap-4">
|
||||
{#each federatedIdentities as identity, i (identity)}
|
||||
{#each federatedIdentities as identity, i (i)}
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<Field.Label>Identity {i + 1}</Field.Label>
|
||||
<Field.Label>{m.federated_identity_number({ number: i + 1 })}</Field.Label>
|
||||
{#if federatedIdentities.length > 0}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => removeFederatedIdentity(i)}
|
||||
aria-label="Remove federated identity"
|
||||
aria-label={m.remove_federated_identity()}
|
||||
{disabled}
|
||||
>
|
||||
<LucideMinus data-icon="inline-start" />
|
||||
@@ -80,7 +109,7 @@
|
||||
|
||||
<div class="grid grid-cols-1 gap-5 md:grid-cols-2">
|
||||
<Field.Field>
|
||||
<Field.Label required for="issuer-{i}">Issuer</Field.Label>
|
||||
<Field.Label required for="issuer-{i}">{m.issuer()}</Field.Label>
|
||||
<Input
|
||||
id="issuer-{i}"
|
||||
placeholder="https://example.com/"
|
||||
@@ -95,10 +124,10 @@
|
||||
</Field.Field>
|
||||
|
||||
<Field.Field>
|
||||
<Field.Label for="subject-{i}">Subject</Field.Label>
|
||||
<Field.Label for="subject-{i}">{m.subject()}</Field.Label>
|
||||
<Input
|
||||
id="subject-{i}"
|
||||
placeholder="Defaults to the client ID"
|
||||
placeholder={m.defaults_to_the_client_id()}
|
||||
value={identity.subject || ''}
|
||||
oninput={(e) => updateFederatedIdentity(i, 'subject', e.currentTarget.value)}
|
||||
aria-invalid={!!getFieldError(i, 'subject')}
|
||||
@@ -110,10 +139,10 @@
|
||||
</Field.Field>
|
||||
|
||||
<Field.Field>
|
||||
<Field.Label for="audience-{i}">Audience</Field.Label>
|
||||
<Field.Label for="audience-{i}">{m.audience()}</Field.Label>
|
||||
<Input
|
||||
id="audience-{i}"
|
||||
placeholder="Defaults to the Pocket ID URL"
|
||||
placeholder={m.defaults_to_the_appname_url({ appName: $appConfigStore.appName })}
|
||||
value={identity.audience || ''}
|
||||
oninput={(e) => updateFederatedIdentity(i, 'audience', e.currentTarget.value)}
|
||||
aria-invalid={!!getFieldError(i, 'audience')}
|
||||
@@ -124,20 +153,54 @@
|
||||
{/if}
|
||||
</Field.Field>
|
||||
|
||||
<Field.Field>
|
||||
<Field.Label for="jwks-{i}">JWKS URL</Field.Label>
|
||||
<Input
|
||||
id="jwks-{i}"
|
||||
placeholder="Defaults to {identity.issuer || '<issuer>'}/.well-known/jwks.json"
|
||||
value={identity.jwks || ''}
|
||||
oninput={(e) => updateFederatedIdentity(i, 'jwks', e.currentTarget.value)}
|
||||
aria-invalid={!!getFieldError(i, 'jwks')}
|
||||
<Field.Field class="md:col-span-2">
|
||||
<Field.Label>{m.signing_keys()}</Field.Label>
|
||||
<RadioGroup.Root
|
||||
class="flex flex-wrap gap-x-6 gap-y-3"
|
||||
value={keySourceFor(i, identity)}
|
||||
onValueChange={(value) => updateKeySource(i, value as KeySource)}
|
||||
{disabled}
|
||||
/>
|
||||
{#if getFieldError(i, 'jwks')}
|
||||
<Field.Error>{getFieldError(i, 'jwks')}</Field.Error>
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<RadioGroup.Item value="jwks" id="key-source-jwks-{i}" />
|
||||
<Label for="key-source-jwks-{i}" class="mb-0 font-normal">{m.jwks_url()}</Label>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<RadioGroup.Item value="publicKeys" id="key-source-public-keys-{i}" />
|
||||
<Label for="key-source-public-keys-{i}" class="mb-0 font-normal">
|
||||
{m.public_keys()}
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
|
||||
{#if keySourceFor(i, identity) === 'publicKeys'}
|
||||
<FederatedIdentityKeysInput
|
||||
id="public-keys-{i}"
|
||||
publicKeys={identity.publicKeys ?? []}
|
||||
onChange={(publicKeys) => updateFederatedIdentity(i, 'publicKeys', publicKeys)}
|
||||
{disabled}
|
||||
/>
|
||||
{#if getFieldError(i, 'publicKeys')}
|
||||
<Field.Error>{getFieldError(i, 'publicKeys')}</Field.Error>
|
||||
{/if}
|
||||
{:else}
|
||||
<Input
|
||||
id="jwks-{i}"
|
||||
aria-label={m.jwks_url()}
|
||||
placeholder={m.defaults_to_the_issuer_jwks_url({
|
||||
issuer: identity.issuer || '<issuer>'
|
||||
})}
|
||||
value={identity.jwks || ''}
|
||||
oninput={(e) => updateFederatedIdentity(i, 'jwks', e.currentTarget.value)}
|
||||
aria-invalid={!!getFieldError(i, 'jwks')}
|
||||
{disabled}
|
||||
/>
|
||||
{#if getFieldError(i, 'jwks')}
|
||||
<Field.Error>{getFieldError(i, 'jwks')}</Field.Error>
|
||||
{/if}
|
||||
{/if}
|
||||
</Field.Field>
|
||||
|
||||
<SwitchWithLabel
|
||||
id="replay-protection-{i}"
|
||||
label={m.replay_protection()}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Field from '$lib/components/ui/field';
|
||||
import { Textarea } from '$lib/components/ui/textarea';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import { describeJwk, getJwkKeyId, parseJwkInput, type Jwk } from '$lib/utils/jwk-util';
|
||||
import { LucideTrash2 } from '@lucide/svelte';
|
||||
|
||||
let {
|
||||
publicKeys,
|
||||
onChange,
|
||||
id,
|
||||
disabled = false
|
||||
}: {
|
||||
publicKeys: Jwk[];
|
||||
onChange: (publicKeys: Jwk[]) => void;
|
||||
id: string;
|
||||
disabled?: boolean;
|
||||
} = $props();
|
||||
|
||||
// The list is owned by this component: the identity it belongs to lives in a store, whose
|
||||
// updates don't propagate back down to this input
|
||||
let keys = $state<Jwk[]>(publicKeys);
|
||||
let pastedKey = $state('');
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
function addKeys() {
|
||||
const result = parseJwkInput(pastedKey);
|
||||
if (!result.ok) {
|
||||
error = result.error;
|
||||
return;
|
||||
}
|
||||
|
||||
// A JWKS may repeat a key that was already added, and duplicate key IDs would make the key
|
||||
// to verify an assertion with ambiguous
|
||||
const existingKeyIds = new Set(keys.map(getJwkKeyId));
|
||||
const duplicate = result.keys.find((key) => existingKeyIds.has(getJwkKeyId(key)));
|
||||
if (duplicate) {
|
||||
error = m.public_key_already_added({ keyId: getJwkKeyId(duplicate) });
|
||||
return;
|
||||
}
|
||||
|
||||
keys = [...keys, ...result.keys];
|
||||
onChange(keys);
|
||||
pastedKey = '';
|
||||
error = null;
|
||||
}
|
||||
|
||||
function removeKey(index: number) {
|
||||
keys = keys.filter((_, i) => i !== index);
|
||||
onChange(keys);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
{#if keys.length > 0}
|
||||
<ul class="flex flex-col gap-2" data-testid="federated-identity-public-keys">
|
||||
{#each keys as key, i (getJwkKeyId(key))}
|
||||
<li
|
||||
class="bg-muted/40 flex items-center justify-between gap-3 rounded-2xl px-4 py-2"
|
||||
data-testid="federated-identity-public-key"
|
||||
>
|
||||
<div class="flex min-w-0 flex-col">
|
||||
<span class="truncate font-mono text-sm">{getJwkKeyId(key)}</span>
|
||||
<span class="text-muted-foreground text-[0.8rem]">{describeJwk(key)}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onclick={() => removeKey(i)}
|
||||
aria-label={m.remove_public_key({ keyId: getJwkKeyId(key) })}
|
||||
{disabled}
|
||||
>
|
||||
<LucideTrash2 />
|
||||
</Button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<Field.Field>
|
||||
<Field.Label for={id}>{m.public_key()}</Field.Label>
|
||||
<Textarea
|
||||
{id}
|
||||
class="font-mono text-xs"
|
||||
rows={4}
|
||||
placeholder={'{"kty": "RSA", "kid": "...", "n": "...", "e": "AQAB"}'}
|
||||
bind:value={pastedKey}
|
||||
aria-invalid={!!error}
|
||||
oninput={() => (error = null)}
|
||||
{disabled}
|
||||
/>
|
||||
{#if error}
|
||||
<Field.Error>{error}</Field.Error>
|
||||
{:else}
|
||||
<Field.Description>{m.paste_public_key_description()}</Field.Description>
|
||||
{/if}
|
||||
</Field.Field>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
type="button"
|
||||
onclick={addKeys}
|
||||
disabled={disabled || !pastedKey.trim()}
|
||||
>
|
||||
{m.add_public_key()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,4 +1,5 @@
|
||||
import test, { expect, Page } from '@playwright/test';
|
||||
import * as jose from 'jose';
|
||||
import { oidcClients, userGroups } from '../data';
|
||||
import { cleanupBackend } from '../utils/cleanup.util';
|
||||
import * as oidcUtil from '../utils/oidc.util';
|
||||
@@ -202,6 +203,106 @@ test('Update OIDC client federated credentials', async ({ page }) => {
|
||||
await expect(card.getByLabel('Issuer')).toHaveValue('https://issuer.example.com');
|
||||
});
|
||||
|
||||
test('Update OIDC client federated credentials with public keys', async ({ page }) => {
|
||||
const client = oidcClients.nextcloud;
|
||||
const issuer = 'https://agent.example.com';
|
||||
const audience = 'api://agent-test';
|
||||
|
||||
async function generatePublicJwk(kid: string) {
|
||||
const { publicKey, privateKey } = await jose.generateKeyPair('ES256', { extractable: true });
|
||||
return { privateKey, jwk: { ...(await jose.exportJWK(publicKey)), kid, alg: 'ES256' } };
|
||||
}
|
||||
|
||||
const first = await generatePublicJwk('agent-key-1');
|
||||
const second = await generatePublicJwk('agent-key-2');
|
||||
const third = await generatePublicJwk('agent-key-3');
|
||||
|
||||
await page.goto(`/settings/admin/oidc-clients/${client.id}#credentials`);
|
||||
|
||||
const card = page.getByTestId('federated-credentials-card');
|
||||
await card.getByRole('button', { name: 'Create', exact: true }).click();
|
||||
await card.getByLabel('Issuer').fill(issuer);
|
||||
await card.getByLabel('Audience').fill(audience);
|
||||
await card.getByRole('radio', { name: 'Public keys' }).click();
|
||||
|
||||
const pasteInput = card.getByLabel('Public key', { exact: true });
|
||||
const addKeyButton = card.getByRole('button', { name: 'Add public key' });
|
||||
const publicKeys = card.getByTestId('federated-identity-public-key');
|
||||
|
||||
// A single JWK is imported as one key
|
||||
await pasteInput.fill(JSON.stringify(first.jwk));
|
||||
await addKeyButton.click();
|
||||
await expect(publicKeys).toHaveCount(1);
|
||||
await expect(publicKeys.first()).toContainText('agent-key-1');
|
||||
|
||||
// A JWKS is imported as one key per entry
|
||||
await pasteInput.fill(JSON.stringify({ keys: [second.jwk, third.jwk] }));
|
||||
await addKeyButton.click();
|
||||
await expect(publicKeys).toHaveCount(3);
|
||||
|
||||
// Private keys are rejected
|
||||
await pasteInput.fill(JSON.stringify(await jose.exportJWK(first.privateKey)));
|
||||
await addKeyButton.click();
|
||||
await expect(card.getByText(/private key material/)).toBeVisible();
|
||||
await expect(publicKeys).toHaveCount(3);
|
||||
|
||||
// Keys without a key ID are rejected, since it is what identifies the key that signed an assertion
|
||||
const { kid, ...withoutKeyId } = third.jwk;
|
||||
await pasteInput.fill(JSON.stringify(withoutKeyId));
|
||||
await addKeyButton.click();
|
||||
await expect(card.getByText(/missing the "kid" property/)).toBeVisible();
|
||||
await expect(publicKeys).toHaveCount(3);
|
||||
|
||||
await card.getByRole('button', { name: `Remove public key ${third.jwk.kid}` }).click();
|
||||
await expect(publicKeys).toHaveCount(2);
|
||||
|
||||
const cardUpdate = page.waitForResponse(
|
||||
(response) =>
|
||||
response.request().method() === 'PUT' &&
|
||||
response.url().endsWith(`/api/oidc/clients/${client.id}`)
|
||||
);
|
||||
await card.getByRole('button', { name: 'Save' }).click();
|
||||
expect((await cardUpdate).ok()).toBeTruthy();
|
||||
|
||||
await page.reload();
|
||||
await expect(card.getByRole('radio', { name: 'Public keys' })).toBeChecked();
|
||||
await expect(publicKeys).toHaveCount(2);
|
||||
await expect(publicKeys.first()).toContainText('agent-key-1');
|
||||
|
||||
// The stored keys authenticate a client assertion signed with the matching private key
|
||||
async function authenticateWithAssertion(key: CryptoKey, keyId: string, jti: string) {
|
||||
const assertion = await new jose.SignJWT({})
|
||||
.setProtectedHeader({ alg: 'ES256', kid: keyId })
|
||||
.setIssuer(issuer)
|
||||
.setSubject(client.id)
|
||||
.setAudience(audience)
|
||||
.setJti(jti)
|
||||
.setIssuedAt()
|
||||
.setExpirationTime('5m')
|
||||
.sign(key);
|
||||
|
||||
return oidcUtil.exchangeCode(page, {
|
||||
grant_type: 'client_credentials',
|
||||
client_id: client.id,
|
||||
client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
|
||||
client_assertion: assertion
|
||||
});
|
||||
}
|
||||
|
||||
for (const key of [first, second]) {
|
||||
const res = await authenticateWithAssertion(
|
||||
key.privateKey,
|
||||
key.jwk.kid,
|
||||
`assertion-${key.jwk.kid}`
|
||||
);
|
||||
expect(res.access_token).toBeTruthy();
|
||||
}
|
||||
|
||||
// The key that was removed can no longer authenticate the client
|
||||
const res = await authenticateWithAssertion(third.privateKey, third.jwk.kid, 'assertion-removed');
|
||||
expect(res.access_token).toBeFalsy();
|
||||
});
|
||||
|
||||
test('Create and delete OIDC client secrets', async ({ page }) => {
|
||||
const oidcClient = oidcClients.nextcloud;
|
||||
await page.goto(`/settings/admin/oidc-clients/${oidcClient.id}#credentials`);
|
||||
|
||||
Reference in New Issue
Block a user