diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 043b26c..33e674e 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -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
diff --git a/__test__/samlIdp.test.ts b/__test__/samlIdp.test.ts
new file mode 100644
index 0000000..3effb5b
--- /dev/null
+++ b/__test__/samlIdp.test.ts
@@ -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: '',
+ });
+ expect(res.errors).toHaveLength(0);
+ expect(res.data?.entity_id).toBe('urn:acme:sp');
+ expect(lastRequest().body).toEqual({
+ metadata_xml: '',
+ });
+ });
+});
diff --git a/src/admin.ts b/src/admin.ts
index c9d89b8..b608103 100644
--- a/src/admin.ts
+++ b/src/admin.ts
@@ -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 =
@@ -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';
@@ -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> =>
+ this.dispatch(
+ '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,
+ );
+
+ // updateSAMLServiceProvider updates a downstream SP's name, endpoints,
+ // certificate, attribute mapping, or active state.
+ updateSAMLServiceProvider = (
+ params: Types.UpdateSAMLServiceProviderRequest,
+ ): Promise> =>
+ this.dispatch(
+ '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,
+ );
+
+ // deleteSAMLServiceProvider deletes a downstream SP by id. DESTRUCTIVE: SSO
+ // for that SP stops working immediately.
+ deleteSAMLServiceProvider = (
+ params: Types.SAMLServiceProviderRequest,
+ ): Promise> =>
+ this.dispatch(
+ '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,
+ );
+
+ // samlServiceProvider returns a single downstream SP by id.
+ samlServiceProvider = (
+ params: Types.SAMLServiceProviderRequest,
+ ): Promise> =>
+ this.dispatch(
+ '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,
+ );
+
+ // listSAMLServiceProviders returns a paginated list of downstream SPs for an org.
+ listSAMLServiceProviders = (
+ params: Types.ListSAMLServiceProvidersRequest,
+ ): Promise> =>
+ this.dispatch(
+ '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,
+ );
+
+ // 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> =>
+ this.dispatch(
+ '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,
+ );
+
+ // retireSAMLIDPKey retires a published-but-superseded key so it stops
+ // appearing in IdP metadata. Cannot retire the current key.
+ retireSAMLIDPKey = (
+ params: Types.RetireSAMLIDPKeyRequest,
+ ): Promise> =>
+ this.dispatch(
+ '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,
+ );
+
+ // listSAMLIDPKeys returns all SAML IdP signing keys for an org (unpaginated).
+ listSAMLIDPKeys = (
+ params: Types.ListSAMLIDPKeysRequest,
+ ): Promise> =>
+ this.dispatch(
+ '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,
+ );
+
+ // 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> =>
+ this.dispatch(
+ '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,
+ );
+
// ---- SCIM endpoints (graphql-only: no proto/REST routes yet) ----
// createScimEndpoint provisions the org's inbound SCIM 2.0 endpoint. The
diff --git a/src/types.ts b/src/types.ts
index c84342c..c7f4430 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -911,6 +911,13 @@ export interface AdminSignupRequest {
// CreateClientResponse (creation and rotation) and never again.
export interface Client {
id: string;
+ // client_id is the public OAuth identifier presented at the authorize/token
+ // endpoints. Distinct from id (internal surrogate key); for a client
+ // created via createClient (no way to set it separately), it defaults to
+ // id server-side, but this MUST NOT be assumed to always equal id — pass
+ // client_id, not id, wherever an OAuth client_id is expected (e.g.
+ // getToken's client_credentials/token-exchange grants).
+ client_id: string;
name: string;
description: string | null;
allowed_scopes: string[];
@@ -1247,6 +1254,121 @@ export interface OrgSAMLConnectionRequest {
org_id?: string | null;
}
+// SAMLServiceProvider is a downstream SAML 2.0 SP that Authorizer (acting as
+// the IdP) issues signed assertions to. This is the inverse of
+// OrgSAMLConnection.
+export interface SAMLServiceProvider {
+ id: string;
+ org_id: string;
+ name: string;
+ // entity_id: the SP entity ID (the AuthnRequest Issuer and assertion Audience).
+ entity_id: string;
+ // acs_url: the SP Assertion Consumer Service URL — the only place assertions
+ // are POSTed. Never taken from the request.
+ acs_url: string;
+ // sp_cert_pem: the SP's optional X.509 signing certificate (PEM).
+ sp_cert_pem: string | null;
+ // name_id_format: SAML NameID format for the Subject (default emailAddress).
+ name_id_format: string | null;
+ // mapped_attributes: JSON mapping profile fields to emitted SAML attribute names.
+ mapped_attributes: string | null;
+ // allow_idp_initiated: whether unsolicited IdP-initiated SSO is permitted.
+ allow_idp_initiated: boolean;
+ is_active: boolean;
+ created_at: number | null;
+ updated_at: number | null;
+}
+
+// SAMLServiceProviders is a paginated list of downstream SAML SPs for an org.
+export interface SAMLServiceProviders {
+ pagination: Pagination;
+ saml_service_providers: SAMLServiceProvider[];
+}
+
+// CreateSAMLServiceProviderRequest registers a downstream SP.
+export interface CreateSAMLServiceProviderRequest {
+ org_id: string;
+ name: string;
+ entity_id: string;
+ acs_url: string;
+ sp_cert_pem?: string | null;
+ // name_id_format: default urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress.
+ name_id_format?: string | null;
+ mapped_attributes?: string | null;
+ // allow_idp_initiated: default false (SP-initiated only).
+ allow_idp_initiated?: boolean | null;
+}
+
+// UpdateSAMLServiceProviderRequest updates an existing downstream SP.
+export interface UpdateSAMLServiceProviderRequest {
+ id: string;
+ name?: string | null;
+ entity_id?: string | null;
+ acs_url?: string | null;
+ sp_cert_pem?: string | null;
+ name_id_format?: string | null;
+ mapped_attributes?: string | null;
+ allow_idp_initiated?: boolean | null;
+ is_active?: boolean | null;
+}
+
+// SAMLServiceProviderRequest looks up (or deletes) a single downstream SP by id.
+export interface SAMLServiceProviderRequest {
+ id: string;
+}
+
+// ListSAMLServiceProvidersRequest is a paginated read of one org's downstream SPs.
+export interface ListSAMLServiceProvidersRequest {
+ org_id: string;
+ pagination?: PaginationRequest | null;
+}
+
+// SAMLIDPKey is a per-org SAML IdP signing keypair. The private key is NEVER
+// projected — only the certificate and rotation status.
+export interface SAMLIDPKey {
+ id: string;
+ org_id: string;
+ // cert_pem: the self-signed X.509 signing certificate (PEM), pinned by SPs.
+ cert_pem: string;
+ algorithm: string;
+ // status: "current" (signs new assertions), "active" (published in metadata,
+ // not signing), or "retired" (neither).
+ status: string;
+ created_at: number | null;
+ updated_at: number | null;
+}
+
+// RotateSAMLIDPCertRequest generates a new current signing keypair for an
+// org's SAML IdP, demoting the previous current key.
+export interface RotateSAMLIDPCertRequest {
+ org_id: string;
+}
+
+// RetireSAMLIDPKeyRequest retires a published-but-superseded key by id.
+// Cannot retire the current key.
+export interface RetireSAMLIDPKeyRequest {
+ id: string;
+}
+
+// ListSAMLIDPKeysRequest lists all SAML IdP signing keys for an org (unpaginated).
+export interface ListSAMLIDPKeysRequest {
+ org_id: string;
+}
+
+// SAMLSPMetadataParseResult is the parsed output of importSAMLSPMetadata. It
+// does NOT create a record — it returns fields to prefill a create call.
+export interface SAMLSPMetadataParseResult {
+ entity_id: string;
+ acs_url: string;
+ certificate: string | null;
+}
+
+// ImportSAMLSPMetadataRequest parses pasted SP metadata XML (NOT a URL — no
+// remote fetch).
+export interface ImportSAMLSPMetadataRequest {
+ metadata_xml: string;
+}
+
// ScimEndpoint is the per-org inbound SCIM 2.0 connection credential. The
// bearer token is NEVER part of this shape — it is returned exactly once in
// CreateScimEndpointResponse (creation and rotation) and never again.