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.