diff --git a/docs/resource-specific-documentation.md b/docs/resource-specific-documentation.md index 622348158..bba2b5489 100644 --- a/docs/resource-specific-documentation.md +++ b/docs/resource-specific-documentation.md @@ -2034,6 +2034,19 @@ The Deploy CLI supports managing client authentication credentials for Private K | `x509_cert` | `self_signed_tls_client_auth` | mTLS (self-signed cert) | | `cert_subject_dn` | `tls_client_auth` | mTLS (CA-signed cert, subject DN) | +### Optional credential fields + +Beyond `name`, `credential_type`, and `pem`, the following optional fields are forwarded to Auth0 when a `public_key` (`private_key_jwt`) credential is created (see the [Management API docs](https://auth0.com/docs/api/management/v2/clients/post-credentials)): + +| Field | Notes | +| ------------------------ | ---------------------------------------------------------------------------- | +| `kid` | Key ID. If omitted, Auth0 auto-generates one. Format: `[0-9a-zA-Z-_]{10,64}` | +| `alg` | Signing algorithm: `RS256`, `RS384`, or `PS256` | +| `expires_at` | ISO 8601 expiry. If omitted, the credential never expires | +| `parse_expiry_from_cert` | Parse the expiry from the X509 certificate supplied in `pem` | + +> **Note:** These fields are honored **only when the credential is created** (matching is by `name`). Changing a field such as `kid` on an existing credential with the same `name` is a no-op — rotate by adding a new credential under a new `name` and removing the old one. `kid` is not exported (Auth0 returns only `name` and `credential_type` on read). + ### Workflow To add or rotate a credential: @@ -2055,6 +2068,8 @@ To add or rotate a credential: credentials: - name: my-key-v2 credential_type: public_key + kid: my-custom-kid # optional; auto-generated if omitted + alg: RS256 # optional pem: | -----BEGIN PUBLIC KEY----- MIIBIjANBgkq... diff --git a/src/tools/auth0/handlers/clientAuthCredentials.ts b/src/tools/auth0/handlers/clientAuthCredentials.ts index a73bbd7d1..adb89fe3c 100644 --- a/src/tools/auth0/handlers/clientAuthCredentials.ts +++ b/src/tools/auth0/handlers/clientAuthCredentials.ts @@ -80,7 +80,16 @@ export default class ClientAuthCredentialsHandler { const clientName = client.name || clientId; // Collect all desired credentials across all auth methods (only pem-bearing entries) - const desired: { name: string; pem?: string; credential_type: string; method: string }[] = []; + const desired: { + name: string; + pem?: string; + credential_type: string; + method: string; + kid?: string; + alg?: string; + expires_at?: string; + parse_expiry_from_cert?: boolean; + }[] = []; if (client.client_authentication_methods) { for (const [methodKey, methodVal] of Object.entries( @@ -94,6 +103,10 @@ export default class ClientAuthCredentialsHandler { pem: cred.pem, credential_type: cred.credential_type || this.inferCredentialType(methodKey), method: methodKey, + kid: cred.kid, + alg: cred.alg, + expires_at: cred.expires_at, + parse_expiry_from_cert: cred.parse_expiry_from_cert, }); } } @@ -139,11 +152,23 @@ export default class ClientAuthCredentialsHandler { const createdIdByName = new Map(); for (const cred of toCreate) { try { - const created = await (this.client.clients.credentials.create as Function)(clientId, { - name: cred.name, - pem: cred.pem, - credential_type: cred.credential_type, - }); + // Forward all API-accepted fields. `!= null` drops unset (undefined) and empty + // (YAML `kid:` → null) values the API rejects, while keeping an explicit `false`. + const createPayload = Object.fromEntries( + Object.entries({ + name: cred.name, + pem: cred.pem, + credential_type: cred.credential_type, + kid: cred.kid, + alg: cred.alg, + expires_at: cred.expires_at, + parse_expiry_from_cert: cred.parse_expiry_from_cert, + }).filter(([, v]) => v != null) + ); + const created = await (this.client.clients.credentials.create as Function)( + clientId, + createPayload + ); log.info( `clientAuthCredentials: created credential "${cred.name}" on client "${clientName}"` ); diff --git a/test/tools/auth0/handlers/clientAuthCredentials.tests.ts b/test/tools/auth0/handlers/clientAuthCredentials.tests.ts index 7b372b375..592e3849d 100644 --- a/test/tools/auth0/handlers/clientAuthCredentials.tests.ts +++ b/test/tools/auth0/handlers/clientAuthCredentials.tests.ts @@ -200,6 +200,65 @@ describe('#clientAuthCredentials handler', () => { ).to.deep.equal([{ id: 'cred_new123' }]); }); + it('should forward kid and other optional fields to create, omitting null/undefined ones', async () => { + const createCalls: any[] = []; + + const client = makeClient({ + clients: { + list: (params) => + mockPagedData(params, 'clients', [{ client_id: 'client1', name: 'My App' }]), + update: () => Promise.resolve({ data: {} }), + credentials: { + list: () => Promise.resolve([]), + create: (clientId, data) => { + createCalls.push({ clientId, data }); + return Promise.resolve({ id: 'cred_new123', name: data.name }); + }, + delete: () => Promise.resolve({}), + }, + }, + }); + + const handler = new clientAuthCredentials({ client, config: makeConfig() }); + const stageFn = Object.getPrototypeOf(handler).processChanges; + await stageFn.apply(handler, [ + { + clients: [ + { + client_id: 'client1', + name: 'My App', + client_authentication_methods: { + private_key_jwt: { + credentials: [ + { + name: 'new-key', + pem: '-----BEGIN PUBLIC KEY-----\nabc\n-----END PUBLIC KEY-----\n', + credential_type: 'public_key', + kid: 'MY_CUSTOM_KID_VALUE', + alg: 'RS256', + // explicit false must be forwarded, not dropped as falsy + parse_expiry_from_cert: false, + // null (e.g. an empty `expires_at:` in YAML) must be dropped, not sent + expires_at: null, + }, + ], + }, + }, + }, + ], + }, + ]); + + expect(createCalls).to.have.lengthOf(1); + const { data } = createCalls[0]; + expect(data.kid).to.equal('MY_CUSTOM_KID_VALUE'); + expect(data.alg).to.equal('RS256'); + // explicit false is a valid value and must be sent, not filtered out + expect(data).to.have.property('parse_expiry_from_cert', false); + // null and undefined optional fields must not be sent (Auth0 rejects nulls) + expect(data).to.not.have.property('expires_at'); + }); + it('should resolve client_id by name when client_id is null (directory mode)', async () => { const createCalls: any[] = [];