Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
53 changes: 53 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1694,6 +1694,59 @@ await descopeClient.management.inboundApplication.deleteConsents({
});
```

### Manage Cross-App Access (XAA / ID-JAG)

Cross-App Access (XAA), built on the OAuth identity-assertion authorization grant (ID-JAG), lets a tenant trust external OIDC issuers so a token minted by a trusted issuer can be exchanged for a Descope token (the RFC 7523 `jwt-bearer` grant). XAA trust is configured **per SSO configuration** of a tenant through the SSO management API, addressed by its `ssoId` (pass an empty string for the tenant's default SSO configuration). The loaded tenant still surfaces XAA state as read-only fields: `idJagEnabled` reports whether XAA is active, and `idJagSettings.issuers` holds the trusted issuers keyed by issuer URL (each an `XAAIssuerSettings`).

Configure the trusted issuers together with the config-level shared group/role mapping. Each issuer supports just-in-time provisioning with the same attribute mapping as the SSO login JIT:

```typescript
import type { XAASettings } from '@descope/node-sdk';

const settings: XAASettings = {
enabled: true,
settings: {
// Trusted issuers keyed by issuer URL.
issuers: {
'https://issuer.example.com': {
jwksUri: 'https://issuer.example.com/.well-known/jwks.json',
signAlgorithm: 'RS256',
userInfoUri: 'https://issuer.example.com/userinfo',
externalIdFieldName: 'sub', // assertion claim used as the login id
jitDisabled: false,
attributeMapping: {
email: 'email',
name: 'name',
group: 'groups', // assertion claim that carries the user's groups
},
},
},
},
// Config-level shared group/role mapping (shared across SAML / OIDC / SCIM / XAA for this ssoId).
roleMappings: [{ groups: ['admins'], roleName: 'Tenant Admin' }],
defaultSSORoles: ['Member'],
};

// Configure XAA for a single SSO configuration ('' = the default SSO configuration).
await descopeClient.management.sso.configureXAASettings('my-tenant-id', '', settings);

// Load the XAA settings for a single SSO configuration.
const { data: xaa } = await descopeClient.management.sso.loadXAASettings('my-tenant-id', '');

// Load the XAA settings for every SSO configuration of the tenant.
const { data: allXaa } = await descopeClient.management.sso.loadAllXAASettings('my-tenant-id');

// Delete the XAA settings of a single SSO configuration (removes its trusted issuers from the tenant).
await descopeClient.management.sso.deleteXAASettings('my-tenant-id', '');

// The tenant's read-only XAA state is also exposed on tenant.load.
const { data: tenant } = await descopeClient.management.tenant.load('my-tenant-id');
console.log('XAA enabled:', tenant.idJagEnabled);
console.log('Trusted issuers:', tenant.idJagSettings?.issuers);
```

> Group-to-role mapping is **not** configured per issuer. `roleMappings`, `defaultSSORoles`, and `fgaMappings` passed to `configureXAASettings` are the config-level shared mapping: the same mapping is shared across SAML / OIDC / SCIM / XAA for that `ssoId`. Each issuer only maps the assertion's groups claim (via `attributeMapping.group`); how those group names resolve to roles is defined once, per SSO configuration. On load, this shared mapping is returned as `groupsMapping` (role references by id and name), mirroring the SAML settings load shape.

### Manage Management Keys

You can create, update, delete, load, or search management keys:
Expand Down
4 changes: 4 additions & 0 deletions lib/management/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,10 @@ export default {
configure: '/v1/mgmt/sso/saml',
metadata: '/v1/mgmt/sso/saml/metadata',
},
xaa: {
settings: '/v1/mgmt/sso/xaa/settings',
settingsAll: '/v1/mgmt/sso/xaa/settings/all',
},
},
jwt: {
update: '/v1/mgmt/jwt/update',
Expand Down
191 changes: 191 additions & 0 deletions lib/management/sso.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -833,4 +833,195 @@ describe('Management SSO', () => {
});
});
});

describe('configureXAASettings', () => {
it('should send the correct request and receive correct response', async () => {
const httpResponse = {
ok: true,
clone: () => ({
json: () => Promise.resolve(),
}),
status: 200,
};
mockHttpClient.post.mockResolvedValue(httpResponse);

const resp = await management.sso.configureXAASettings('t1', '', {
enabled: true,
settings: {
issuers: {
'https://issuer.example.com': {
jwksUri: 'https://issuer.example.com/jwks',
signAlgorithm: 'RS256',
jitDisabled: true,
attributeMapping: { email: 'email', group: 'groups' },
},
},
jwtBearerGrantTypeAudienceToUse: 'clientId',
},
roleMappings: [{ groups: ['g1'], roleName: 'role1' }],
defaultSSORoles: ['Member'],
groupsPriority: ['g1'],
groupPriorityEnabled: true,
allowOverrideRoles: true,
});

expect(mockHttpClient.post).toHaveBeenCalledWith(apiPaths.sso.xaa.settings, {
tenantId: 't1',
enabled: true,
settings: {
issuers: {
'https://issuer.example.com': {
jwksUri: 'https://issuer.example.com/jwks',
signAlgorithm: 'RS256',
jitDisabled: true,
attributeMapping: { email: 'email', group: 'groups' },
},
},
jwtBearerGrantTypeAudienceToUse: 'clientId',
},
roleMappings: [{ groups: ['g1'], roleName: 'role1' }],
defaultSSORoles: ['Member'],
fgaMappings: undefined,
groupsPriority: ['g1'],
groupPriorityEnabled: true,
allowOverrideRoles: true,
});

expect(resp).toEqual({
code: 200,
ok: true,
response: httpResponse,
});
});

it('should send ssoId when provided', async () => {
const httpResponse = {
ok: true,
clone: () => ({
json: () => Promise.resolve(),
}),
status: 200,
};
mockHttpClient.post.mockResolvedValue(httpResponse);

await management.sso.configureXAASettings('t1', 'somessoid', {
enabled: true,
settings: { issuers: { 'https://issuer.example.com': { jwksUri: 'https://jwks' } } },
});

expect(mockHttpClient.post).toHaveBeenCalledWith(apiPaths.sso.xaa.settings, {
tenantId: 't1',
ssoId: 'somessoid',
enabled: true,
settings: { issuers: { 'https://issuer.example.com': { jwksUri: 'https://jwks' } } },
roleMappings: undefined,
defaultSSORoles: undefined,
fgaMappings: undefined,
groupsPriority: undefined,
groupPriorityEnabled: undefined,
allowOverrideRoles: undefined,
});
});
});

describe('loadXAASettings', () => {
it('should send the correct request and receive correct response', async () => {
const mockResponse = {
ssoId: 'somessoid',
enabled: true,
settings: {
issuers: {
'https://issuer.example.com': { jwksUri: 'https://jwks', signAlgorithm: 'RS256' },
},
},
groupsMapping: [{ role: { id: 'r1', name: 'role1' }, groups: ['g1'] }],
defaultSSORoles: ['Member'],
};
const httpResponse = {
ok: true,
json: () => mockResponse,
clone: () => ({
json: () => Promise.resolve(mockResponse),
}),
status: 200,
};
mockHttpClient.get.mockResolvedValue(httpResponse);

const resp = await management.sso.loadXAASettings('t1', 'somessoid');

expect(mockHttpClient.get).toHaveBeenCalledWith(apiPaths.sso.xaa.settings, {
queryParams: { tenantId: 't1', ssoId: 'somessoid' },
});

expect(resp).toEqual({
code: 200,
ok: true,
response: httpResponse,
data: mockResponse,
});
});
});

describe('loadAllXAASettings', () => {
it('should send the correct request and unwrap the XAASettings array', async () => {
const mockResponse = {
XAASettings: [
{ ssoId: 'sso1', enabled: true },
{ ssoId: 'sso2', enabled: false },
],
};
const httpResponse = {
ok: true,
json: () => mockResponse,
clone: () => ({
json: () => Promise.resolve(mockResponse),
}),
status: 200,
};
mockHttpClient.get.mockResolvedValue(httpResponse);

const resp = await management.sso.loadAllXAASettings('t1');

expect(mockHttpClient.get).toHaveBeenCalledWith(apiPaths.sso.xaa.settingsAll, {
queryParams: { tenantId: 't1' },
});

expect(resp).toEqual({
code: 200,
ok: true,
response: httpResponse,
data: [
{ ssoId: 'sso1', enabled: true },
{ ssoId: 'sso2', enabled: false },
],
});
});
});

describe('deleteXAASettings', () => {
it('should send the correct request and receive correct response', async () => {
const httpResponse = {
ok: true,
json: () => {},
clone: () => ({
json: () => Promise.resolve({}),
}),
status: 200,
};
mockHttpClient.delete.mockResolvedValue(httpResponse);

const resp = await management.sso.deleteXAASettings('t1', 'somessoid');

expect(mockHttpClient.delete).toHaveBeenCalledWith(apiPaths.sso.xaa.settings, {
queryParams: { tenantId: 't1', ssoId: 'somessoid' },
});

expect(resp).toEqual({
code: 200,
ok: true,
response: httpResponse,
data: {},
});
});
});
});
60 changes: 60 additions & 0 deletions lib/management/sso.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
SSOSAMLSettings,
SSOSAMLByMetadataSettings,
SSOSettings,
XAASettings,
XAASettingsResponse,
} from './types';

function transformSettingsResponse(data) {
Expand Down Expand Up @@ -37,6 +39,25 @@ function transformAllSettingsResponse(data) {
return res;
}

// Rename each loaded group->role mapping's `role.name` to `roleName` (dropping `role`), matching the
// SAML transform above, so a loaded XAA config's groupsMapping round-trips back into configureXAASettings.
function transformXAASettingsResponse(setting: any): XAASettingsResponse {
Comment thread
dorsha marked this conversation as resolved.
const ready = setting;
if (ready?.groupsMapping) {
ready.groupsMapping = ready.groupsMapping.map((gm: any) => {
const rm = gm;
rm.roleName = rm.role.name;
delete rm.role;
return rm;
});
}
return ready;
}

function transformAllXAASettingsResponse(data): XAASettingsResponse[] {
return ((data.XAASettings as XAASettingsResponse[]) ?? []).map(transformXAASettingsResponse);
}

const withSSOSettings = (httpClient: HttpClient) => ({
/**
* @deprecated Use loadSettings instead
Expand Down Expand Up @@ -201,6 +222,45 @@ const withSSOSettings = (httpClient: HttpClient) => ({
}),
(data) => transformAllSettingsResponse(data),
),
configureXAASettings: (
tenantId: string,
ssoId: string,
settings: XAASettings,
): Promise<SdkResponse<never>> =>
transformResponse(
httpClient.post(apiPaths.sso.xaa.settings, {
tenantId,
...(ssoId ? { ssoId } : {}),
enabled: settings.enabled,
settings: settings.settings,
roleMappings: settings.roleMappings,
defaultSSORoles: settings.defaultSSORoles,
fgaMappings: settings.fgaMappings,
groupsPriority: settings.groupsPriority,
groupPriorityEnabled: settings.groupPriorityEnabled,
allowOverrideRoles: settings.allowOverrideRoles,
}),
),
loadXAASettings: (tenantId: string, ssoId?: string): Promise<SdkResponse<XAASettingsResponse>> =>
transformResponse<XAASettingsResponse>(
httpClient.get(apiPaths.sso.xaa.settings, {
queryParams: { tenantId, ...(ssoId ? { ssoId } : {}) },
}),
(data) => transformXAASettingsResponse(data),
),
loadAllXAASettings: (tenantId: string): Promise<SdkResponse<XAASettingsResponse[]>> =>
transformResponse<XAASettingsResponse[]>(
httpClient.get(apiPaths.sso.xaa.settingsAll, {
queryParams: { tenantId },
}),
(data) => transformAllXAASettingsResponse(data),
),
deleteXAASettings: (tenantId: string, ssoId?: string): Promise<SdkResponse<never>> =>
transformResponse(
httpClient.delete(apiPaths.sso.xaa.settings, {
queryParams: { tenantId, ...(ssoId ? { ssoId } : {}) },
}),
),
});

export default withSSOSettings;
Loading
Loading