From e5b8e36e8afc64be88020b73e608bfad62a2bab8 Mon Sep 17 00:00:00 2001 From: ItalyPaleAle <43508+ItalyPaleAle@users.noreply.github.com> Date: Tue, 18 Aug 2026 08:47:25 -0700 Subject: [PATCH] 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 --- backend/internal/dto/oidc_dto.go | 17 +- backend/internal/model/oidc.go | 11 +- .../internal/oidc/federated_client_auth.go | 36 +++- .../oidc/federated_client_auth_test.go | 111 +++++++++++++ backend/internal/service/oidc_service.go | 33 +++- backend/internal/utils/jwk/public_key.go | 101 ++++++++++++ backend/internal/utils/jwk/public_key_test.go | 154 ++++++++++++++++++ frontend/messages/en.json | 26 +++ frontend/src/lib/types/oidc.type.ts | 2 + frontend/src/lib/utils/jwk-util.ts | 125 ++++++++++++++ ...c-client-federated-credentials-card.svelte | 2 + .../federated-identities-input.svelte | 101 +++++++++--- .../federated-identity-keys-input.svelte | 111 +++++++++++++ tests/specs/oidc-client-settings.spec.ts | 101 ++++++++++++ 14 files changed, 887 insertions(+), 44 deletions(-) create mode 100644 backend/internal/utils/jwk/public_key.go create mode 100644 backend/internal/utils/jwk/public_key_test.go create mode 100644 frontend/src/lib/utils/jwk-util.ts create mode 100644 frontend/src/routes/settings/admin/oidc-clients/federated-identity-keys-input.svelte diff --git a/backend/internal/dto/oidc_dto.go b/backend/internal/dto/oidc_dto.go index 9ad737eb..3f2991f9 100644 --- a/backend/internal/dto/oidc_dto.go +++ b/backend/internal/dto/oidc_dto.go @@ -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 { diff --git a/backend/internal/model/oidc.go b/backend/internal/model/oidc.go index 0024c1ed..58947ca2 100644 --- a/backend/internal/model/oidc.go +++ b/backend/internal/model/oidc.go @@ -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) { diff --git a/backend/internal/oidc/federated_client_auth.go b/backend/internal/oidc/federated_client_auth.go index 6d5d10f8..74a51ab4 100644 --- a/backend/internal/oidc/federated_client_auth.go +++ b/backend/internal/oidc/federated_client_auth.go @@ -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 diff --git a/backend/internal/oidc/federated_client_auth_test.go b/backend/internal/oidc/federated_client_auth_test.go index 9ad529d7..3a173ac7 100644 --- a/backend/internal/oidc/federated_client_auth_test.go +++ b/backend/internal/oidc/federated_client_auth_test.go @@ -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()) + }) +} diff --git a/backend/internal/service/oidc_service.go b/backend/internal/service/oidc_service.go index 1a788ad5..e35f44fd 100644 --- a/backend/internal/service/oidc_service.go +++ b/backend/internal/service/oidc_service.go @@ -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 { diff --git a/backend/internal/utils/jwk/public_key.go b/backend/internal/utils/jwk/public_key.go new file mode 100644 index 00000000..e4ef6ff8 --- /dev/null +++ b/backend/internal/utils/jwk/public_key.go @@ -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 +} diff --git a/backend/internal/utils/jwk/public_key_test.go b/backend/internal/utils/jwk/public_key_test.go new file mode 100644 index 00000000..490c8664 --- /dev/null +++ b/backend/internal/utils/jwk/public_key_test.go @@ -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) + }) +} diff --git a/frontend/messages/en.json b/frontend/messages/en.json index f4fd9142..d0b4a966 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -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", diff --git a/frontend/src/lib/types/oidc.type.ts b/frontend/src/lib/types/oidc.type.ts index 4df0dacc..ded44dc6 100644 --- a/frontend/src/lib/types/oidc.type.ts +++ b/frontend/src/lib/types/oidc.type.ts @@ -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; }; diff --git a/frontend/src/lib/utils/jwk-util.ts b/frontend/src/lib/utils/jwk-util.ts new file mode 100644 index 00000000..e14c2e14 --- /dev/null +++ b/frontend/src/lib/utils/jwk-util.ts @@ -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 = { + 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 { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/frontend/src/routes/settings/admin/oidc-clients/[id]/oidc-client-federated-credentials-card.svelte b/frontend/src/routes/settings/admin/oidc-clients/[id]/oidc-client-federated-credentials-card.svelte index a968569b..4ae1d4ac 100644 --- a/frontend/src/routes/settings/admin/oidc-clients/[id]/oidc-client-federated-credentials-card.svelte +++ b/frontend/src/routes/settings/admin/oidc-clients/[id]/oidc-client-federated-credentials-card.svelte @@ -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 } ]; diff --git a/frontend/src/routes/settings/admin/oidc-clients/federated-identities-input.svelte b/frontend/src/routes/settings/admin/oidc-clients/federated-identities-input.svelte index e64b4b46..a68230c2 100644 --- a/frontend/src/routes/settings/admin/oidc-clients/federated-identities-input.svelte +++ b/frontend/src/routes/settings/admin/oidc-clients/federated-identities-input.svelte @@ -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([]); + + 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( @@ -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 @@
- {#each federatedIdentities as identity, i (identity)} + {#each federatedIdentities as identity, i (i)}
- Identity {i + 1} + {m.federated_identity_number({ number: i + 1 })} {#if federatedIdentities.length > 0} + + {/each} + + {/if} + + + {m.public_key()} +