Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,4 @@ jobs:
- name: Run integration tests
run: pnpm test # Make sure this command runs your integration tests
env:
AUTHORIZER_IMAGE: quay.io/authorizer/authorizer:2.3.0
AUTHORIZER_IMAGE: quay.io/authorizer/authorizer:2.4.0-rc.7
173 changes: 173 additions & 0 deletions __test__/samlIdp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
// Unit tests (no docker) for the SAML IdP admin surface (Authorizer acting as
// Identity Provider for downstream SPs): create/update/delete/get/list SPs,
// signing-key rotation/retirement/listing, and SP-metadata import. Same
// mocked-cross-fetch pattern as tokenGrants.test.ts's admin section.
import crossFetch from 'cross-fetch';
import { AuthorizerAdmin } from '../src';

jest.mock('cross-fetch', () => ({
__esModule: true,
default: jest.fn(),
}));

const fetchMock = crossFetch as unknown as jest.Mock;

function mockJsonResponse(body: unknown, ok = true, status = 200) {
fetchMock.mockResolvedValueOnce({
ok,
status,
text: async () => JSON.stringify(body),
});
}

function lastRequest(): { url: string; body: any } {
const [url, init] = fetchMock.mock.calls[fetchMock.mock.calls.length - 1];
return { url, body: JSON.parse(init.body as string) };
}

beforeEach(() => fetchMock.mockReset());

const adminConfig = {
authorizerURL: 'http://localhost:8080',
adminSecret: 'secret',
};

describe('AuthorizerAdmin SAML IdP methods', () => {
it('createSAMLServiceProvider posts the mutation over graphql', async () => {
const admin = new AuthorizerAdmin(adminConfig);
mockJsonResponse({
data: {
_create_saml_service_provider: {
id: 'sp1',
org_id: 'o1',
name: 'acme-sp',
entity_id: 'urn:acme:sp',
acs_url: 'https://acme.example.com/acs',
sp_cert_pem: null,
name_id_format: null,
mapped_attributes: null,
allow_idp_initiated: false,
is_active: true,
created_at: 1,
updated_at: 1,
},
},
});
const res = await admin.createSAMLServiceProvider({
org_id: 'o1',
name: 'acme-sp',
entity_id: 'urn:acme:sp',
acs_url: 'https://acme.example.com/acs',
});
expect(res.errors).toHaveLength(0);
expect(res.data?.id).toBe('sp1');

const { url, body } = lastRequest();
expect(url).toBe('http://localhost:8080/graphql');
expect(body.operationName).toBe('_create_saml_service_provider');
expect(body.variables).toEqual({
params: {
org_id: 'o1',
name: 'acme-sp',
entity_id: 'urn:acme:sp',
acs_url: 'https://acme.example.com/acs',
},
});
});

it('samlServiceProvider unwraps the proto-gateway wrapper over rest', async () => {
const admin = new AuthorizerAdmin({ ...adminConfig, protocol: 'rest' });
mockJsonResponse({
saml_service_provider: { id: 'sp1', name: 'acme-sp' },
});
const res = await admin.samlServiceProvider({ id: 'sp1' });
expect(res.errors).toHaveLength(0);
expect(res.data).toEqual({ id: 'sp1', name: 'acme-sp' });
expect(lastRequest().url).toBe(
'http://localhost:8080/v1/admin/saml_service_provider',
);
});

it('listSAMLServiceProviders returns the paginated list over rest', async () => {
const admin = new AuthorizerAdmin({ ...adminConfig, protocol: 'rest' });
mockJsonResponse({
saml_service_providers: [{ id: 'sp1' }],
pagination: { limit: '10', page: '1', offset: '0', total: '1' },
});
const res = await admin.listSAMLServiceProviders({ org_id: 'o1' });
expect(res.errors).toHaveLength(0);
expect(res.data?.saml_service_providers).toHaveLength(1);
// int64 strings from the proto gateway are coerced to numbers
expect(res.data?.pagination.total).toBe(1);
expect(lastRequest().body).toEqual({ org_id: 'o1' });
});

it('deleteSAMLServiceProvider posts the mutation and returns a message', async () => {
const admin = new AuthorizerAdmin(adminConfig);
mockJsonResponse({
data: { _delete_saml_service_provider: { message: 'deleted' } },
});
const res = await admin.deleteSAMLServiceProvider({ id: 'sp1' });
expect(res.errors).toHaveLength(0);
expect(res.data?.message).toBe('deleted');
expect(lastRequest().body.operationName).toBe(
'_delete_saml_service_provider',
);
});

it('rotateSAMLIDPCert unwraps saml_idp_key over rest', async () => {
const admin = new AuthorizerAdmin({ ...adminConfig, protocol: 'rest' });
mockJsonResponse({
saml_idp_key: { id: 'k2', org_id: 'o1', status: 'current' },
});
const res = await admin.rotateSAMLIDPCert({ org_id: 'o1' });
expect(res.errors).toHaveLength(0);
expect(res.data?.status).toBe('current');
expect(lastRequest().url).toBe(
'http://localhost:8080/v1/admin/rotate_saml_idp_cert',
);
});

it('retireSAMLIDPKey posts the mutation over graphql', async () => {
const admin = new AuthorizerAdmin(adminConfig);
mockJsonResponse({
data: { _retire_saml_idp_key: { message: 'retired' } },
});
const res = await admin.retireSAMLIDPKey({ id: 'k1' });
expect(res.errors).toHaveLength(0);
expect(res.data?.message).toBe('retired');
});

it('listSAMLIDPKeys unwraps the bare array over rest', async () => {
const admin = new AuthorizerAdmin({ ...adminConfig, protocol: 'rest' });
mockJsonResponse({
saml_idp_keys: [
{ id: 'k1', status: 'current' },
{ id: 'k2', status: 'retired' },
],
});
const res = await admin.listSAMLIDPKeys({ org_id: 'o1' });
expect(res.errors).toHaveLength(0);
expect(res.data).toHaveLength(2);
expect(res.data?.[0].status).toBe('current');
});

it('importSAMLSPMetadata unwraps the result over rest', async () => {
const admin = new AuthorizerAdmin({ ...adminConfig, protocol: 'rest' });
mockJsonResponse({
result: {
entity_id: 'urn:acme:sp',
acs_url: 'https://acme.example.com/acs',
certificate: null,
},
});
const res = await admin.importSAMLSPMetadata({
metadata_xml: '<EntityDescriptor/>',
});
expect(res.errors).toHaveLength(0);
expect(res.data?.entity_id).toBe('urn:acme:sp');
expect(lastRequest().body).toEqual({
metadata_xml: '<EntityDescriptor/>',
});
});
});
194 changes: 193 additions & 1 deletion src/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ const emailTemplateFragment =
const auditLogFragment =
'id actor_id actor_type actor_email action resource_type resource_id ip_address user_agent metadata created_at';
const clientFragment =
'id name description allowed_scopes is_active created_at updated_at';
'id client_id name description allowed_scopes is_active created_at updated_at';
const trustedIssuerFragment =
'id service_account_id name issuer_url key_source_type jwks_url expected_aud subject_claim allowed_subjects issuer_type is_active spiffe_refresh_hint_seconds created_at updated_at';
const organizationFragment =
Expand All @@ -30,6 +30,10 @@ const orgOIDCConnectionFragment =
'id org_id name issuer_url sso_client_id scopes redirect_uri is_active created_at updated_at';
const orgSAMLConnectionFragment =
'id org_id name idp_entity_id idp_sso_url sp_entity_id acs_url attribute_mapping allow_idp_initiated is_active created_at updated_at';
const samlServiceProviderFragment =
'id org_id name entity_id acs_url sp_cert_pem name_id_format mapped_attributes allow_idp_initiated is_active created_at updated_at';
const samlIdpKeyFragment =
'id org_id cert_pem algorithm status created_at updated_at';
const scimEndpointFragment = 'id org_id enabled created_at updated_at';
const orgDomainFragment = 'domain org_id verified_at created_at updated_at';

Expand Down Expand Up @@ -1291,6 +1295,194 @@ export class AuthorizerAdmin {
{ params },
);

// ---- SAML IdP (Authorizer as Identity Provider for downstream SPs) ----

// createSAMLServiceProvider registers a downstream SP that Authorizer (acting
// as the IdP) issues signed assertions to.
createSAMLServiceProvider = (
params: Types.CreateSAMLServiceProviderRequest,
): Promise<Types.ApiResponse<Types.SAMLServiceProvider>> =>
this.dispatch<Types.SAMLServiceProvider>(
'CreateSAMLServiceProvider',
['graphql', 'rest'],
{
query: `mutation _create_saml_service_provider($params: CreateSAMLServiceProviderRequest!) { _create_saml_service_provider(params: $params) { ${samlServiceProviderFragment} } }`,
operationName: '_create_saml_service_provider',
op: '_create_saml_service_provider',
},
{
method: 'POST',
path: '/v1/admin/create_saml_service_provider',
unwrap: 'saml_service_provider',
},
{ params },
params as unknown as Record<string, unknown>,
);

// updateSAMLServiceProvider updates a downstream SP's name, endpoints,
// certificate, attribute mapping, or active state.
updateSAMLServiceProvider = (
params: Types.UpdateSAMLServiceProviderRequest,
): Promise<Types.ApiResponse<Types.SAMLServiceProvider>> =>
this.dispatch<Types.SAMLServiceProvider>(
'UpdateSAMLServiceProvider',
['graphql', 'rest'],
{
query: `mutation _update_saml_service_provider($params: UpdateSAMLServiceProviderRequest!) { _update_saml_service_provider(params: $params) { ${samlServiceProviderFragment} } }`,
operationName: '_update_saml_service_provider',
op: '_update_saml_service_provider',
},
{
method: 'POST',
path: '/v1/admin/update_saml_service_provider',
unwrap: 'saml_service_provider',
},
{ params },
params as unknown as Record<string, unknown>,
);

// deleteSAMLServiceProvider deletes a downstream SP by id. DESTRUCTIVE: SSO
// for that SP stops working immediately.
deleteSAMLServiceProvider = (
params: Types.SAMLServiceProviderRequest,
): Promise<Types.ApiResponse<Types.Response>> =>
this.dispatch<Types.Response>(
'DeleteSAMLServiceProvider',
['graphql', 'rest'],
{
query:
'mutation _delete_saml_service_provider($params: SAMLServiceProviderRequest!) { _delete_saml_service_provider(params: $params) { message } }',
operationName: '_delete_saml_service_provider',
op: '_delete_saml_service_provider',
},
{ method: 'POST', path: '/v1/admin/delete_saml_service_provider' },
{ params },
params as unknown as Record<string, unknown>,
);

// samlServiceProvider returns a single downstream SP by id.
samlServiceProvider = (
params: Types.SAMLServiceProviderRequest,
): Promise<Types.ApiResponse<Types.SAMLServiceProvider>> =>
this.dispatch<Types.SAMLServiceProvider>(
'GetSAMLServiceProvider',
['graphql', 'rest'],
{
query: `query _saml_service_provider($params: SAMLServiceProviderRequest!) { _saml_service_provider(params: $params) { ${samlServiceProviderFragment} } }`,
operationName: '_saml_service_provider',
op: '_saml_service_provider',
},
{
method: 'POST',
path: '/v1/admin/saml_service_provider',
unwrap: 'saml_service_provider',
},
{ params },
params as unknown as Record<string, unknown>,
);

// listSAMLServiceProviders returns a paginated list of downstream SPs for an org.
listSAMLServiceProviders = (
params: Types.ListSAMLServiceProvidersRequest,
): Promise<Types.ApiResponse<Types.SAMLServiceProviders>> =>
this.dispatch<Types.SAMLServiceProviders>(
'ListSAMLServiceProviders',
['graphql', 'rest'],
{
query: `query _list_saml_service_providers($params: ListSAMLServiceProvidersRequest!) { _list_saml_service_providers(params: $params) { pagination { ${paginationFragment} } saml_service_providers { ${samlServiceProviderFragment} } } }`,
operationName: '_list_saml_service_providers',
op: '_list_saml_service_providers',
},
{ method: 'POST', path: '/v1/admin/saml_service_providers' },
{ params },
params as unknown as Record<string, unknown>,
);

// rotateSAMLIDPCert generates a new current signing keypair for an org's SAML
// IdP; the previously-current key stays "active" (still published) until retired.
rotateSAMLIDPCert = (
params: Types.RotateSAMLIDPCertRequest,
): Promise<Types.ApiResponse<Types.SAMLIDPKey>> =>
this.dispatch<Types.SAMLIDPKey>(
'RotateSAMLIDPCert',
['graphql', 'rest'],
{
query: `mutation _rotate_saml_idp_cert($params: RotateSAMLIDPCertRequest!) { _rotate_saml_idp_cert(params: $params) { ${samlIdpKeyFragment} } }`,
operationName: '_rotate_saml_idp_cert',
op: '_rotate_saml_idp_cert',
},
{
method: 'POST',
path: '/v1/admin/rotate_saml_idp_cert',
unwrap: 'saml_idp_key',
},
{ params },
params as unknown as Record<string, unknown>,
);

// retireSAMLIDPKey retires a published-but-superseded key so it stops
// appearing in IdP metadata. Cannot retire the current key.
retireSAMLIDPKey = (
params: Types.RetireSAMLIDPKeyRequest,
): Promise<Types.ApiResponse<Types.Response>> =>
this.dispatch<Types.Response>(
'RetireSAMLIDPKey',
['graphql', 'rest'],
{
query:
'mutation _retire_saml_idp_key($params: RetireSAMLIDPKeyRequest!) { _retire_saml_idp_key(params: $params) { message } }',
operationName: '_retire_saml_idp_key',
op: '_retire_saml_idp_key',
},
{ method: 'POST', path: '/v1/admin/retire_saml_idp_key' },
{ params },
params as unknown as Record<string, unknown>,
);

// listSAMLIDPKeys returns all SAML IdP signing keys for an org (unpaginated).
listSAMLIDPKeys = (
params: Types.ListSAMLIDPKeysRequest,
): Promise<Types.ApiResponse<Types.SAMLIDPKey[]>> =>
this.dispatch<Types.SAMLIDPKey[]>(
'ListSAMLIDPKeys',
['graphql', 'rest'],
{
query: `query _list_saml_idp_keys($params: ListSAMLIDPKeysRequest!) { _list_saml_idp_keys(params: $params) { ${samlIdpKeyFragment} } }`,
operationName: '_list_saml_idp_keys',
op: '_list_saml_idp_keys',
},
{
method: 'POST',
path: '/v1/admin/saml_idp_keys',
unwrap: 'saml_idp_keys',
},
{ params },
params as unknown as Record<string, unknown>,
);

// importSAMLSPMetadata parses pasted SP metadata XML and returns the
// extracted entity_id / acs_url / certificate. It does NOT create a record.
importSAMLSPMetadata = (
params: Types.ImportSAMLSPMetadataRequest,
): Promise<Types.ApiResponse<Types.SAMLSPMetadataParseResult>> =>
this.dispatch<Types.SAMLSPMetadataParseResult>(
'ImportSAMLSPMetadata',
['graphql', 'rest'],
{
query:
'mutation _import_saml_sp_metadata($params: ImportSAMLSPMetadataRequest!) { _import_saml_sp_metadata(params: $params) { entity_id acs_url certificate } }',
operationName: '_import_saml_sp_metadata',
op: '_import_saml_sp_metadata',
},
{
method: 'POST',
path: '/v1/admin/import_saml_sp_metadata',
unwrap: 'result',
},
{ params },
params as unknown as Record<string, unknown>,
);

// ---- SCIM endpoints (graphql-only: no proto/REST routes yet) ----

// createScimEndpoint provisions the org's inbound SCIM 2.0 endpoint. The
Expand Down
Loading
Loading