Skip to content

Commit

Permalink
feat(core): add PATCH/GET /saml-applications/:id APIs
Browse files Browse the repository at this point in the history
  • Loading branch information
darcyYe committed Nov 22, 2024
1 parent 0795ba9 commit ed0dd8e
Show file tree
Hide file tree
Showing 10 changed files with 236 additions and 66 deletions.
100 changes: 100 additions & 0 deletions packages/core/src/saml-applications/libraries/saml-applications.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import {
ApplicationType,
type SamlApplicationResponse,
type PatchSamlApplication,
} from '@logto/schemas';
import { generateStandardId } from '@logto/shared';
import { removeUndefinedKeys } from '@silverhand/essentials';

import type Queries from '#src/tenants/Queries.js';
import assertThat from '#src/utils/assert-that.js';

import { ensembleSamlApplication, generateKeyPairAndCertificate } from './utils.js';

export const createSamlApplicationsLibrary = (queries: Queries) => {
const {
applications: { findApplicationById, updateApplicationById },
samlApplicationSecrets: {
insertSamlApplicationSecret,
findSamlApplicationSecretsByApplicationId,
},
samlApplicationConfigs: {
findSamlApplicationConfigByApplicationId,
updateSamlApplicationConfig,
},
} = queries;

const createNewSamlApplicationSecretForApplication = async (
applicationId: string,
// Set certificate life span to 1 year by default.
lifeSpanInDays = 365
) => {
const { privateKey, certificate, notAfter } = await generateKeyPairAndCertificate(
lifeSpanInDays
);

return insertSamlApplicationSecret({
id: generateStandardId(),
applicationId,
privateKey,
certificate,
expiresAt: Math.floor(notAfter.getTime() / 1000),
active: false,
});

Check warning on line 43 in packages/core/src/saml-applications/libraries/saml-applications.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/saml-applications/libraries/saml-applications.ts#L28-L43

Added lines #L28 - L43 were not covered by tests
};

const findSamlApplicationById = async (
applicationId: string
): Promise<SamlApplicationResponse> => {
const application = await findApplicationById(applicationId);
assertThat(application.type === ApplicationType.SAML, 'application.saml.saml_application_only');

const [samlConfig, samlSecrets] = await Promise.all([
findSamlApplicationConfigByApplicationId(application.id),
findSamlApplicationSecretsByApplicationId(application.id),
]);

return ensembleSamlApplication({ application, samlConfig, samlSecret: samlSecrets });

Check warning on line 57 in packages/core/src/saml-applications/libraries/saml-applications.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/saml-applications/libraries/saml-applications.ts#L47-L57

Added lines #L47 - L57 were not covered by tests
};

const updateSamlApplicationById = async (
id: string,
patchApplicationObject: PatchSamlApplication
): Promise<SamlApplicationResponse> => {
const { name, description, customData, config } = patchApplicationObject;

const [updatedApplication, upToDateSamlConfig, samlSecrets] = await Promise.all([
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
name || description || customData
? updateApplicationById(
id,
removeUndefinedKeys({
name,
description,
customData,
})
)
: findApplicationById(id),
config
? updateSamlApplicationConfig({
set: config,
where: { applicationId: id },
jsonbMode: 'replace',
})
: findSamlApplicationConfigByApplicationId(id),
findSamlApplicationSecretsByApplicationId(id),
]);

return ensembleSamlApplication({
application: updatedApplication,
samlConfig: upToDateSamlConfig,
samlSecret: samlSecrets,
});

Check warning on line 92 in packages/core/src/saml-applications/libraries/saml-applications.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/saml-applications/libraries/saml-applications.ts#L61-L92

Added lines #L61 - L92 were not covered by tests
};

return {
createNewSamlApplicationSecretForApplication,
findSamlApplicationById,
updateSamlApplicationById,
};
};
34 changes: 0 additions & 34 deletions packages/core/src/saml-applications/libraries/secrets.ts

This file was deleted.

22 changes: 22 additions & 0 deletions packages/core/src/saml-applications/libraries/utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import crypto from 'node:crypto';

import {
type SamlApplicationResponse,
type SamlApplicationSecret,
type Application,
type SamlApplicationConfig,
} from '@logto/schemas';
import { addDays } from 'date-fns';
import forge from 'node-forge';

Expand Down Expand Up @@ -56,3 +62,19 @@ const createCertificate = (keypair: forge.pki.KeyPair, lifeSpanInDays: number) =
notAfter,
};
};

export const ensembleSamlApplication = ({
application,
samlConfig,
samlSecret: secrets,
}: {
application: Application;
samlConfig: Pick<SamlApplicationConfig, 'attributeMapping' | 'entityId' | 'acsUrl'>;
samlSecret: readonly SamlApplicationSecret[];
}): SamlApplicationResponse => {
return {
...application,
...samlConfig,
secrets: [...secrets],
};
};

Check warning on line 80 in packages/core/src/saml-applications/libraries/utils.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/saml-applications/libraries/utils.ts#L67-L80

Added lines #L67 - L80 were not covered by tests
2 changes: 1 addition & 1 deletion packages/core/src/saml-applications/queries/configs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export const createSamlApplicationConfigQueries = (pool: CommonQueryMethods) =>
const updateSamlApplicationConfig = buildUpdateWhereWithPool(pool)(SamlApplicationConfigs, true);

const findSamlApplicationConfigByApplicationId = async (applicationId: string) =>
pool.maybeOne<SamlApplicationConfig>(sql`
pool.one<SamlApplicationConfig>(sql`

Check warning on line 19 in packages/core/src/saml-applications/queries/configs.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/saml-applications/queries/configs.ts#L19

Added line #L19 was not covered by tests
select ${sql.join(Object.values(fields), sql`, `)}
from ${table}
where ${fields.applicationId}=${applicationId}
Expand Down
54 changes: 50 additions & 4 deletions packages/core/src/saml-applications/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
ApplicationType,
BindingType,
samlApplicationCreateGuard,
samlApplicationPatchGuard,
samlApplicationResponseGuard,
} from '@logto/schemas';
import { generateStandardId } from '@logto/shared';
Expand All @@ -13,7 +14,7 @@ import koaGuard from '#src/middleware/koa-guard.js';
import { buildOidcClientMetadata } from '#src/oidc/utils.js';
import { generateInternalSecret } from '#src/routes/applications/application-secret.js';
import type { ManagementApiRouter, RouterInitArgs } from '#src/routes/types.js';
import { ensembleSamlApplication } from '#src/saml-applications/routes/utils.js';
import { ensembleSamlApplication } from '#src/saml-applications/libraries/utils.js';
import assertThat from '#src/utils/assert-that.js';

export default function samlApplicationRoutes<T extends ManagementApiRouter>(
Expand All @@ -24,15 +25,19 @@ export default function samlApplicationRoutes<T extends ManagementApiRouter>(
samlApplicationConfigs: { insertSamlApplicationConfig },
} = queries;
const {
samlApplicationSecrets: { createNewSamlApplicationSecretForApplication },
samlApplications: {
createNewSamlApplicationSecretForApplication,
findSamlApplicationById,
updateSamlApplicationById,
},
} = libraries;

router.post(
'/saml-applications',
koaGuard({
body: samlApplicationCreateGuard,
response: samlApplicationResponseGuard,
status: [201, 400],
status: [201, 400, 422],
}),
async (ctx, next) => {
const { name, description, customData, config } = ctx.guard.body;
Expand Down Expand Up @@ -67,7 +72,48 @@ export default function samlApplicationRoutes<T extends ManagementApiRouter>(
]);

ctx.status = 201;
ctx.body = ensembleSamlApplication({ application, samlConfig, samlSecret });
ctx.body = ensembleSamlApplication({ application, samlConfig, samlSecret: [samlSecret] });

return next();
}

Check warning on line 78 in packages/core/src/saml-applications/routes/index.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/saml-applications/routes/index.ts#L75-L78

Added lines #L75 - L78 were not covered by tests
);

router.get(
'/saml-applications/:id',
koaGuard({
params: z.object({
id: z.string(),
}),
response: samlApplicationResponseGuard,
status: [200, 400, 404],
}),
async (ctx, next) => {
const { id } = ctx.guard.params;

const samlApplication = await findSamlApplicationById(id);

ctx.status = 200;
ctx.body = samlApplication;

return next();
}

Check warning on line 99 in packages/core/src/saml-applications/routes/index.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/saml-applications/routes/index.ts#L91-L99

Added lines #L91 - L99 were not covered by tests
);

router.patch(
'/saml-applications/:id',
koaGuard({
params: z.object({ id: z.string() }),
body: samlApplicationPatchGuard,
response: samlApplicationResponseGuard,
status: [200, 400, 404, 422],
}),
async (ctx, next) => {
const { id } = ctx.guard.params;

const updatedSamlApplication = await updateSamlApplicationById(id, ctx.guard.body);

ctx.status = 200;
ctx.body = updatedSamlApplication;

Check warning on line 116 in packages/core/src/saml-applications/routes/index.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/saml-applications/routes/index.ts#L111-L116

Added lines #L111 - L116 were not covered by tests

return next();
}
Expand Down
22 changes: 0 additions & 22 deletions packages/core/src/saml-applications/routes/utils.ts

This file was deleted.

4 changes: 2 additions & 2 deletions packages/core/src/tenants/Libraries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { createSocialLibrary } from '#src/libraries/social.js';
import { createSsoConnectorLibrary } from '#src/libraries/sso-connector.js';
import { createUserLibrary } from '#src/libraries/user.js';
import { createVerificationStatusLibrary } from '#src/libraries/verification-status.js';
import { createSamlApplicationSecretsLibrary } from '#src/saml-applications/libraries/secrets.js';
import { createSamlApplicationsLibrary } from '#src/saml-applications/libraries/saml-applications.js';

import type Queries from './Queries.js';

Expand All @@ -38,7 +38,7 @@ export default class Libraries {
passcodes = createPasscodeLibrary(this.queries, this.connectors);
applications = createApplicationLibrary(this.queries);
verificationStatuses = createVerificationStatusLibrary(this.queries);
samlApplicationSecrets = createSamlApplicationSecretsLibrary(this.queries);
samlApplications = createSamlApplicationsLibrary(this.queries);
roleScopes = createRoleScopeLibrary(this.queries);
domains = createDomainLibrary(this.queries);
protectedApps = createProtectedAppLibrary(this.queries);
Expand Down
17 changes: 16 additions & 1 deletion packages/integration-tests/src/api/saml-application.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { type SamlApplicationResponse, type CreateSamlApplication } from '@logto/schemas';
import {
type SamlApplicationResponse,
type CreateSamlApplication,
type PatchSamlApplication,
} from '@logto/schemas';

import { authedAdminApi } from './api.js';

Expand All @@ -11,3 +15,14 @@ export const createSamlApplication = async (createSamlApplication: CreateSamlApp

export const deleteSamlApplication = async (id: string) =>
authedAdminApi.delete(`saml-applications/${id}`);

export const updateSamlApplication = async (
id: string,
patchSamlApplication: PatchSamlApplication
) =>
authedAdminApi
.patch(`saml-applications/${id}`, { json: patchSamlApplication })
.json<SamlApplicationResponse>();

export const getSamlApplication = async (id: string) =>
authedAdminApi.get(`saml-applications/${id}`).json<SamlApplicationResponse>();
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { ApplicationType, BindingType } from '@logto/schemas';

import { createApplication, deleteApplication } from '#src/api/application.js';
import { createSamlApplication, deleteSamlApplication } from '#src/api/saml-application.js';
import {
createSamlApplication,
deleteSamlApplication,
updateSamlApplication,
getSamlApplication,
} from '#src/api/saml-application.js';
import { expectRejects } from '#src/helpers/index.js';
import { devFeatureTest } from '#src/utils.js';

Expand Down Expand Up @@ -66,6 +71,31 @@ describe('SAML application', () => {
await deleteSamlApplication(createdSamlApplication.id);
});

it('should be able to update SAML application and get the updated one', async () => {
const createdSamlApplication = await createSamlApplication({
name: 'test',
description: 'test',
});

const newConfig = {
acsUrl: {
binding: BindingType.POST,
url: 'https://example.logto.io/sso/saml',
},
};
const updatedSamlApplication = await updateSamlApplication(createdSamlApplication.id, {
name: 'updated',
config: newConfig,
});
const upToDateSamlApplication = await getSamlApplication(createdSamlApplication.id);

expect(updatedSamlApplication).toEqual(upToDateSamlApplication);
expect(updatedSamlApplication.name).toEqual('updated');
expect(updatedSamlApplication.acsUrl).toEqual(newConfig.acsUrl);

await deleteSamlApplication(updatedSamlApplication.id);
});

it('can not delete non-SAML applications with `DEL /saml-applications/:id` API', async () => {
const application = await createApplication('test-non-saml-app', ApplicationType.Traditional, {
isThirdParty: true,
Expand Down
Loading

0 comments on commit ed0dd8e

Please sign in to comment.