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:
ItalyPaleAle
2026-08-18 08:47:25 -07:00
parent 22354581df
commit e5b8e36e8a
14 changed files with 887 additions and 44 deletions

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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

View File

@@ -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())
})
}

View File

@@ -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 {

View 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
}

View 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)
})
}

View File

@@ -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",

View File

@@ -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;
};

View 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);
}

View File

@@ -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
}
];

View File

@@ -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()}

View File

@@ -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>

View File

@@ -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`);