Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions docs/resource-specific-documentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -1790,3 +1790,36 @@ Contents of `My API Client.json` (deploy-time, with pem):
```

> **Note:** The `pem` field must be supplied manually from your key generation step. Never commit private keys — only the public key PEM goes in the config.

## Token Vault Privileged Access

> **Early Access:** `token_vault_privileged_access` requires the `token_vault_subject_type_jwt_ea_rollout` feature flag to be enabled on the tenant, and writes additionally require the `create:client_token_vault_privileged_access` / `update:client_token_vault_privileged_access` scopes. This field is export-only in the Deploy CLI (see below), so these requirements affect only manual configuration on the tenant, not the CLI.

The Deploy CLI exports the `token_vault_privileged_access` property on clients, which hardens a privileged Token Vault worker by restricting the caller IPs, connections, and scopes it may use at runtime.

> **Export-only field:** `token_vault_privileged_access` is exported for visibility but **is not deployed** by the Deploy CLI — it is stripped from create/update payloads. The Management API requires a `credentials` array (tenant-specific credential `id` references) whenever the object is sent, and those ids are never persisted by the CLI because they are not portable across tenants. Sending the object without them fails validation, and sending exported ids would re-send stale references on a cross-tenant deploy. Manage `token_vault_privileged_access` directly on the tenant.

| Field | Type | Description |
| -------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `ip_allowlist` | array of strings | IPv4/IPv6 addresses or CIDR ranges permitted to call token exchange on behalf of this client. |
| `grants` | array of objects | Connection/scope pin objects. Each has a `connection` (name) and `scopes` (array). Max 5 connections; max 20 scopes total. |

Exported shape (`credentials` is stripped; `ip_allowlist` and `grants` are kept for visibility):

```yaml
clients:
- name: My Token Vault Privileged App
app_type: non_interactive
token_vault_privileged_access:
ip_allowlist:
- '192.168.1.0/24'
- '10.0.0.1'
grants:
- connection: google-oauth2
scopes:
- 'https://www.googleapis.com/auth/calendar.readonly'
- connection: slack
scopes:
- 'chat:write'
- 'channels:read'
```
18 changes: 18 additions & 0 deletions examples/yaml/tenant.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,24 @@ clients:
allow_any_profile_of_type:
- "custom_authentication"
- "on_behalf_of_token_exchange"
-
name: "My Token Vault Privileged App"
app_type: "non_interactive"
# Export-only: token_vault_privileged_access is exported for visibility but is NOT
# deployed by the CLI (it is stripped from import). Manage it directly on the tenant.
# See docs/resource-specific-documentation.md#token-vault-privileged-access.
token_vault_privileged_access:
ip_allowlist:
- "192.168.1.0/24"
- "10.0.0.1"
grants:
- connection: "google-oauth2"
scopes:
- "https://www.googleapis.com/auth/calendar.readonly"
- connection: "slack"
scopes:
- "chat:write"
- "channels:read"
-
name: "My CIMD Client"
external_client_id: "https://mcpserver.example.com/.well-known/client.json"
Expand Down
91 changes: 90 additions & 1 deletion src/tools/auth0/handlers/clients.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,45 @@ const myOrganizationConfigurationSchema = {
required: ['allowed_strategies', 'connection_deletion_behavior'],
};

const tokenVaultPrivilegedAccessSchema = {
type: ['object', 'null'],
description:
'Settings for Token Vault Privileged Access, hardening a privileged client by restricting the caller IPs, connections, and scopes it may use at runtime. Early Access, gated by the token_vault_subject_type_jwt_ea_rollout feature flag. Export-only: this object is exported for visibility but is not deployed by the Deploy CLI (it is stripped from create/update payloads because the required credentials are tenant-specific ids). Manage it directly on the tenant.',
properties: {
ip_allowlist: {
type: 'array',
description:
'IPv4/IPv6 addresses or CIDR ranges permitted to call token exchange on behalf of this privileged client. When the EA flag is on, this is required (non-empty) on create if token_vault_privileged_access is being set.',
items: {
type: 'string',
},
},
grants: {
type: 'array',
description:
'Connection/scope pin objects restricting which federated connections and OAuth scopes the privileged client may use at runtime. Maximum 5 connections; maximum 20 scopes in total across all connections.',
items: {
type: 'object',
properties: {
connection: {
type: 'string',
description:
'The connection name (e.g. google-oauth2). Validated at runtime; the connection need not exist at configuration time.',
},
scopes: {
type: 'array',
description: 'The OAuth scopes permitted for that connection.',
items: {
type: 'string',
},
},
},
required: ['connection', 'scopes'],
},
},
},
};

export const schema = {
type: 'array',
items: {
Expand Down Expand Up @@ -366,6 +405,7 @@ export const schema = {
},
},
},
token_vault_privileged_access: tokenVaultPrivilegedAccessSchema,
third_party_security_mode: {
type: 'string',
enum: ['strict', 'permissive'],
Expand Down Expand Up @@ -534,6 +574,45 @@ const createClientSanitizer = (clients: Client[]): ClientSanitizerChain => {
};
};

/**
* On export, remove `credentials` from `token_vault_privileged_access`.
*
* Auth0 returns these as tenant-specific credential references (`{ id }`) — never
* names or key material — so they are not portable across tenants and must never be
* written to disk. `ip_allowlist` and `grants` are kept for visibility. Mutates and
* returns the same client.
*/
const stripTokenVaultCredentialsOnExport = (client: Client): Client => {
const tvpa = (client as { token_vault_privileged_access?: { credentials?: unknown } })
.token_vault_privileged_access;

if (tvpa && 'credentials' in tvpa) {
delete tvpa.credentials;
}

return client;
};

/**
* On write (create/update), remove the entire `token_vault_privileged_access` object.
*
* The Management API requires `credentials` whenever `token_vault_privileged_access`
* is present in the payload, but those credentials are tenant-specific ids that the
* Deploy CLI intentionally never persists (see stripTokenVaultCredentialsOnExport).
* Sending the object without `credentials` fails validation ("Missing required
* property: credentials"); sending it with exported ids would re-send stale ids on a
* cross-tenant deploy. The Deploy CLI therefore does not manage this field — it is
* export-only. Manage `token_vault_privileged_access` directly on the tenant.
* Mutates and returns the same client.
*/
const stripTokenVaultPrivilegedAccessOnWrite = (client: Client): Client => {
if ('token_vault_privileged_access' in client) {
delete (client as { token_vault_privileged_access?: unknown }).token_vault_privileged_access;
}

return client;
};

export default class ClientHandler extends DefaultAPIHandler {
existing: Client[];

Expand Down Expand Up @@ -676,6 +755,11 @@ export default class ClientHandler extends DefaultAPIHandler {
}
delete item.client_authentication_methods;

// token_vault_privileged_access requires tenant-specific credential ids that
// the Deploy CLI never persists. Strip the whole object on write so we neither
// re-send stale ids nor fail validation for the missing credentials property.
stripTokenVaultPrivilegedAccessOnWrite(item);

return item;
});
};
Expand All @@ -701,7 +785,12 @@ export default class ClientHandler extends DefaultAPIHandler {
...(shouldExcludeThirdPartyClients(this.config) && { is_first_party: true }),
});

const sanitized = createClientSanitizer(clients).sanitizeCrossOriginAuth(false).get();
const sanitized = createClientSanitizer(clients)
.sanitizeCrossOriginAuth(false)
.get()
// token_vault_privileged_access.credentials are returned as tenant-specific
// ids — strip them so they are never exported to disk.
.map(stripTokenVaultCredentialsOnExport);

// Enrich credential stubs with name and credential_type for export.
// Auth0 does not return pem on read — it is never exported.
Expand Down
119 changes: 119 additions & 0 deletions test/tools/auth0/handlers/clients.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,30 @@ describe('#clients handler', () => {
expect(wasCreateCalled).to.be.true;
});

it('should pass schema validation for a valid token_vault_privileged_access property', () => {
const ajv = new Ajv({ useDefaults: true, nullable: true });
const valid = ajv.validate(clients.schema, [
{
name: 'clientWithTokenVault',
token_vault_privileged_access: {
ip_allowlist: ['192.168.1.0/24', '10.0.0.1'],
grants: [
{
connection: 'google-oauth2',
scopes: ['https://www.googleapis.com/auth/calendar.readonly'],
},
{
connection: 'slack',
scopes: ['chat:write', 'channels:read'],
},
],
},
},
]);
expect(valid).to.equal(true);
expect(ajv.errors).to.be.null;
});

it('should allow valid session_transfer delegation property in client', async () => {
const clientWithDelegation = {
name: 'clientWithDelegation',
Expand Down Expand Up @@ -1874,6 +1898,41 @@ describe('#clients handler', () => {
expect(cred.kid).to.equal(undefined);
expect(cred.alg).to.equal(undefined);
});

it('should strip token_vault_privileged_access.credentials on export while keeping ip_allowlist and grants', async () => {
const auth0 = {
clients: {
create: () => Promise.resolve({ data: {} }),
update: () => Promise.resolve({ data: {} }),
delete: () => Promise.resolve({ data: {} }),
list: (params) =>
mockPagedData(params, 'clients', [
{
client_id: 'client1',
name: 'Privileged App',
token_vault_privileged_access: {
credentials: [{ id: 'cred_abc' }],
ip_allowlist: ['10.0.0.1'],
grants: [{ connection: 'google-oauth2', scopes: ['openid'] }],
},
},
]),
},
connectionProfiles: { list: (params) => mockPagedData(params, 'connectionProfiles', []) },
userAttributeProfiles: {
list: (params) => mockPagedData(params, 'userAttributeProfiles', []),
},
pool,
};

const handler = new clients.default({ client: pageClient(auth0), config });
const result = await handler.getType();

const tvpa = result[0].token_vault_privileged_access;
expect(tvpa).to.not.have.property('credentials');
expect(tvpa.ip_allowlist).to.deep.equal(['10.0.0.1']);
expect(tvpa.grants).to.deep.equal([{ connection: 'google-oauth2', scopes: ['openid'] }]);
});
});

describe('#clients pem stripping in processChanges', () => {
Expand Down Expand Up @@ -1955,6 +2014,66 @@ describe('#clients handler', () => {
expect(updatePayloads['client1']).to.not.have.property('client_authentication_methods');
expect(updatePayloads['client2']).to.not.have.property('client_authentication_methods');
});

it('should strip the entire token_vault_privileged_access object from create/update', async () => {
const createPayloads = [];
const updatePayloads = {};
const auth0 = {
clients: {
create: (data) => {
createPayloads.push(data);
return Promise.resolve({ data });
},
update: (clientId, data) => {
updatePayloads[clientId] = data;
return Promise.resolve({ data });
},
delete: () => Promise.resolve({ data: {} }),
list: (params) =>
mockPagedData(params, 'clients', [
{
client_id: 'client1',
name: 'Existing Privileged App',
},
]),
},
connectionProfiles: { list: (params) => mockPagedData(params, 'connectionProfiles', []) },
userAttributeProfiles: {
list: (params) => mockPagedData(params, 'userAttributeProfiles', []),
},
pool,
};

const handler = new clients.default({ client: pageClient(auth0), config });
const stageFn = Object.getPrototypeOf(handler).processChanges;

const tvpa = {
credentials: [{ id: 'cred_stale' }],
ip_allowlist: ['10.0.0.1'],
grants: [{ connection: 'google-oauth2', scopes: ['openid'] }],
};

await stageFn.apply(handler, [
{
clients: [
// Existing client -> update
{
client_id: 'client1',
name: 'Existing Privileged App',
token_vault_privileged_access: { ...tvpa },
},
// New client -> create
{ name: 'New Privileged App', token_vault_privileged_access: { ...tvpa } },
],
},
]);

// The Management API requires credentials whenever token_vault_privileged_access
// is sent, but those are non-portable tenant-specific ids the CLI never persists.
// The whole object is therefore stripped on write — the field is export-only.
expect(updatePayloads['client1']).to.not.have.property('token_vault_privileged_access');
expect(createPayloads[0]).to.not.have.property('token_vault_privileged_access');
});
});

describe('#clients validate', () => {
Expand Down