0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-02-17 22:04:19 -05:00

feat(core,schemas,phrases): add application sign-in-experience apis (#5129)

add application sign-in-experience apis
This commit is contained in:
simeng-li 2023-12-21 11:28:10 +08:00 committed by GitHub
parent 541ea2919c
commit 6e82e99725
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
31 changed files with 368 additions and 35 deletions

View file

@ -60,10 +60,7 @@ export const createApplicationLibrary = (queries: Queries) => {
const validateThirdPartyApplicationById = async (applicationId: string) => {
const application = await findApplicationById(applicationId);
assertThat(
application.isThirdParty,
'application.user_consent_scopes_only_for_third_party_applications'
);
assertThat(application.isThirdParty, 'application.third_party_application_only');
};
// Guard that all scopes exist

View file

@ -0,0 +1,37 @@
import { ApplicationSignInExperiences, type ApplicationSignInExperience } from '@logto/schemas';
import { convertToIdentifiers } from '@logto/shared';
import { sql, type CommonQueryMethods } from 'slonik';
import { buildInsertIntoWithPool } from '#src/database/insert-into.js';
import { buildUpdateWhereWithPool } from '#src/database/update-where.js';
const createApplicationSignInExperienceQueries = (pool: CommonQueryMethods) => {
const insert = buildInsertIntoWithPool(pool)(ApplicationSignInExperiences, {
returning: true,
});
const safeFindSignInExperienceByApplicationId = async (applicationId: string) => {
const { table, fields } = convertToIdentifiers(ApplicationSignInExperiences);
return pool.maybeOne<ApplicationSignInExperience>(sql`
select ${sql.join(Object.values(fields), sql`, `)}
from ${table}
where ${fields.applicationId}=${applicationId}
`);
};
const update = buildUpdateWhereWithPool(pool)(ApplicationSignInExperiences, true);
const updateByApplicationId = async (
applicationId: string,
set: Partial<Omit<ApplicationSignInExperience, 'applicationId' | 'tenantId'>>
) => update({ set, where: { applicationId }, jsonbMode: 'replace' });
return {
insert,
safeFindSignInExperienceByApplicationId,
updateByApplicationId,
};
};
export default createApplicationSignInExperienceQueries;

View file

@ -0,0 +1,33 @@
{
"paths": {
"/api/applications/{applicationId}/sign-in-experience": {
"put": {
"summary": "Update application level sign-in experience",
"description": "Update application level sign-in experience for the specified application. Create a new sign-in experience if it does not exist. \n - Only branding properties and terms links customization is supported for now. \n\n - Only third-party applications can be customized for now. \n\n - Application level sign-in experience customization is optional, if provided, it will override the default branding and terms links.",
"responses": {
"200": {
"description": "The application's sign-in experience was successfully updated."
},
"201": {
"description": "A new application level sign-in experience settings was successfully created."
},
"404": {
"description": "The application does not exist."
}
}
},
"get": {
"summary": "Get the application level sign-in experience",
"description": "Get application level sign-in experience for a given application. \n - Only branding properties and terms links customization is supported for now. \n\n - Only third-party applications can have the sign-in experience customization for now.",
"responses": {
"200": {
"description": "Returns the application's application level sign-in experience."
},
"404": {
"description": "The application does not exist or the application level sign-in experience does not exist."
}
}
}
}
}
}

View file

@ -0,0 +1,120 @@
import {
ApplicationSignInExperiences,
applicationSignInExperienceCreateGuard,
} from '@logto/schemas';
import { object, string } from 'zod';
import RequestError from '#src/errors/RequestError/index.js';
import koaGuard from '#src/middleware/koa-guard.js';
import type { AuthedRouter, RouterInitArgs } from '../types.js';
function applicationSignInExperienceRoutes<T extends AuthedRouter>(
...[
router,
{
queries: {
applications: { findApplicationById },
applicationSignInExperiences: {
safeFindSignInExperienceByApplicationId,
insert,
updateByApplicationId,
},
},
libraries: {
applications: { validateThirdPartyApplicationById },
},
},
]: RouterInitArgs<T>
) {
/**
* Customize the branding of an application.
*
* - Only branding and terms links customization is supported for now. e.g. per app level sign-in method customization is not supported.
* - Only third-party applications can be customized for now.
* - Application level sign-in experience customization is optional, if provided, it will override the default branding and terms links.
* - We use application ID as the unique identifier of the application level sign-in experience ID.
*/
router.put(
'/applications/:applicationId/sign-in-experience',
koaGuard({
params: object({
applicationId: string(),
}),
body: applicationSignInExperienceCreateGuard,
response: ApplicationSignInExperiences.guard,
status: [200, 201, 404],
}),
async (ctx, next) => {
const {
params: { applicationId },
body,
} = ctx.guard;
await validateThirdPartyApplicationById(applicationId);
const applicationSignInExperience = await safeFindSignInExperienceByApplicationId(
applicationId
);
if (applicationSignInExperience) {
const updatedApplicationSignInExperience = await updateByApplicationId(applicationId, body);
ctx.body = updatedApplicationSignInExperience;
ctx.status = 200;
return next();
}
const newApplicationSignInExperience = await insert({
...body,
applicationId,
});
ctx.body = newApplicationSignInExperience;
ctx.status = 201;
return next();
}
);
router.get(
'/applications/:applicationId/sign-in-experience',
koaGuard({
params: object({
applicationId: string(),
}),
response: ApplicationSignInExperiences.guard,
status: [200, 404],
}),
async (ctx, next) => {
const {
params: { applicationId },
} = ctx.guard;
await findApplicationById(applicationId);
const applicationSignInExperience = await safeFindSignInExperienceByApplicationId(
applicationId
);
if (!applicationSignInExperience) {
throw new RequestError({
code: 'entity.not_exists_with_id',
name: ApplicationSignInExperiences.table,
id: applicationId,
status: 404,
});
}
ctx.body = applicationSignInExperience;
ctx.status = 200;
return next();
}
);
}
export default applicationSignInExperienceRoutes;

View file

@ -12,6 +12,7 @@ import koaAuth from '../middleware/koa-auth/index.js';
import adminUserRoutes from './admin-user/index.js';
import applicationRoleRoutes from './applications/application-role.js';
import applicationSignInExperienceRoutes from './applications/application-sign-in-experience.js';
import applicationUserConsentScopeRoutes from './applications/application-user-consent-scope.js';
import applicationRoutes from './applications/application.js';
import authnRoutes from './authn.js';
@ -49,6 +50,7 @@ const createRouters = (tenant: TenantContext) => {
if (EnvSet.values.isDevFeaturesEnabled) {
applicationUserConsentScopeRoutes(managementRouter, tenant);
applicationSignInExperienceRoutes(managementRouter, tenant);
}
logtoConfigRoutes(managementRouter, tenant);

View file

@ -1,6 +1,7 @@
import type { CommonQueryMethods } from 'slonik';
import { type WellKnownCache } from '#src/caches/well-known.js';
import createApplicationSignInExperienceQueries from '#src/queries/application-sign-in-experience.js';
import { createApplicationQueries } from '#src/queries/application.js';
import { createApplicationsRolesQueries } from '#src/queries/applications-roles.js';
import { createConnectorQueries } from '#src/queries/connector.js';
@ -26,6 +27,7 @@ import { createVerificationStatusQueries } from '#src/queries/verification-statu
export default class Queries {
applications = createApplicationQueries(this.pool);
applicationSignInExperiences = createApplicationSignInExperienceQueries(this.pool);
connectors = createConnectorQueries(this.pool, this.wellKnownCache);
customPhrases = createCustomPhraseQueries(this.pool, this.wellKnownCache);
logs = createLogQueries(this.pool);

View file

@ -0,0 +1,19 @@
import {
type ApplicationSignInExperienceCreate,
type ApplicationSignInExperience,
} from '@logto/schemas';
import { authedAdminApi } from './api.js';
export const setApplicationSignInExperience = async (
applicationId: string,
payload: ApplicationSignInExperienceCreate
) =>
authedAdminApi
.put(`applications/${applicationId}/sign-in-experience`, { json: payload })
.json<ApplicationSignInExperience>();
export const getApplicationSignInExperience = async (applicationId: string) =>
authedAdminApi
.get(`applications/${applicationId}/sign-in-experience`)
.json<ApplicationSignInExperience>();

View file

@ -0,0 +1,118 @@
import {
ApplicationType,
type ApplicationSignInExperienceCreate,
type Application,
} from '@logto/schemas';
import {
getApplicationSignInExperience,
setApplicationSignInExperience,
} from '#src/api/application-sign-in-experience.js';
import { createApplication, deleteApplication } from '#src/api/application.js';
import { expectRejects } from '#src/helpers/index.js';
describe('application sign in experience', () => {
const applications = new Map<string, Application>();
const applicationSignInExperiences: ApplicationSignInExperienceCreate = {
branding: {
logoUrl: 'https://logto.dev/logo.png',
darkLogoUrl: 'https://logto.dev/logo-dark.png',
},
termsOfUseUrl: 'https://logto.dev/terms-of-use',
privacyPolicyUrl: 'https://logto.dev/privacy-policy',
displayName: 'Logto Demo',
};
beforeAll(async () => {
const firstPartyApp = await createApplication('first-party-application', ApplicationType.SPA);
const thirdPartyApp = await createApplication(
'third-party-application',
ApplicationType.Traditional,
{
isThirdParty: true,
}
);
applications.set('firstPartyApp', firstPartyApp);
applications.set('thirdPartyApp', thirdPartyApp);
});
afterAll(async () => {
await Promise.all(
Array.from(applications.values()).map(async (applications) =>
deleteApplication(applications.id)
)
);
});
it('should throw 404 if application does not exist', async () => {
await expectRejects(
setApplicationSignInExperience('non-existent-application', applicationSignInExperiences),
{
code: 'entity.not_exists_with_id',
statusCode: 404,
}
);
});
it('should throw 400 if application is not third-party', async () => {
await expectRejects(
setApplicationSignInExperience(
applications.get('firstPartyApp')!.id,
applicationSignInExperiences
),
{
code: 'application.third_party_application_only',
statusCode: 400,
}
);
});
it('should set new application sign in experience', async () => {
const application = applications.get('thirdPartyApp')!;
const signInExperience = await setApplicationSignInExperience(
application.id,
applicationSignInExperiences
);
expect(signInExperience).toMatchObject({
...applicationSignInExperiences,
applicationId: application.id,
tenantId: application.tenantId,
});
const getSignInExperience = await getApplicationSignInExperience(application.id);
expect(getSignInExperience).toMatchObject(signInExperience);
});
it('should update existing application sign in experience', async () => {
const application = applications.get('thirdPartyApp')!;
const signInExperience = await setApplicationSignInExperience(application.id, {
...applicationSignInExperiences,
displayName: '',
});
expect(signInExperience).toMatchObject({
...applicationSignInExperiences,
displayName: null,
applicationId: application.id,
tenantId: application.tenantId,
});
const getSignInExperience = await getApplicationSignInExperience(application.id);
expect(getSignInExperience).toMatchObject(signInExperience);
});
it('should throw 404 if application sign in experience does not exist', async () => {
const application = applications.get('firstPartyApp')!;
await expectRejects(getApplicationSignInExperience(application.id), {
code: 'entity.not_exists_with_id',
statusCode: 404,
});
});
});

View file

@ -77,7 +77,7 @@ describe('assign user consent scopes to application', () => {
resourceScopes: Array.from(resourceScopes.values()),
}),
{
code: 'application.user_consent_scopes_only_for_third_party_applications',
code: 'application.third_party_application_only',
statusCode: 400,
}
);

View file

@ -7,8 +7,7 @@ const application = {
invalid_third_party_application_type:
'Only traditional web applications can be marked as a third-party app.',
/** UNTRANSLATED */
user_consent_scopes_only_for_third_party_applications:
'Only third-party applications can manage user consent scopes.',
third_party_application_only: 'The feature is only available for third-party applications.',
/** UNTRANSLATED */
user_consent_scopes_not_found: 'Invalid user consent scopes.',
};

View file

@ -4,8 +4,7 @@ const application = {
invalid_role_type: 'Can not assign user type role to machine to machine application.',
invalid_third_party_application_type:
'Only traditional web applications can be marked as a third-party app.',
user_consent_scopes_only_for_third_party_applications:
'Only third-party applications can manage user consent scopes.',
third_party_application_only: 'The feature is only available for third-party applications.',
user_consent_scopes_not_found: 'Invalid user consent scopes.',
};

View file

@ -7,8 +7,7 @@ const application = {
invalid_third_party_application_type:
'Only traditional web applications can be marked as a third-party app.',
/** UNTRANSLATED */
user_consent_scopes_only_for_third_party_applications:
'Only third-party applications can manage user consent scopes.',
third_party_application_only: 'The feature is only available for third-party applications.',
/** UNTRANSLATED */
user_consent_scopes_not_found: 'Invalid user consent scopes.',
};

View file

@ -7,8 +7,7 @@ const application = {
invalid_third_party_application_type:
'Only traditional web applications can be marked as a third-party app.',
/** UNTRANSLATED */
user_consent_scopes_only_for_third_party_applications:
'Only third-party applications can manage user consent scopes.',
third_party_application_only: 'The feature is only available for third-party applications.',
/** UNTRANSLATED */
user_consent_scopes_not_found: 'Invalid user consent scopes.',
};

View file

@ -7,8 +7,7 @@ const application = {
invalid_third_party_application_type:
'Only traditional web applications can be marked as a third-party app.',
/** UNTRANSLATED */
user_consent_scopes_only_for_third_party_applications:
'Only third-party applications can manage user consent scopes.',
third_party_application_only: 'The feature is only available for third-party applications.',
/** UNTRANSLATED */
user_consent_scopes_not_found: 'Invalid user consent scopes.',
};

View file

@ -7,8 +7,7 @@ const application = {
invalid_third_party_application_type:
'Only traditional web applications can be marked as a third-party app.',
/** UNTRANSLATED */
user_consent_scopes_only_for_third_party_applications:
'Only third-party applications can manage user consent scopes.',
third_party_application_only: 'The feature is only available for third-party applications.',
/** UNTRANSLATED */
user_consent_scopes_not_found: 'Invalid user consent scopes.',
};

View file

@ -6,8 +6,7 @@ const application = {
invalid_third_party_application_type:
'Only traditional web applications can be marked as a third-party app.',
/** UNTRANSLATED */
user_consent_scopes_only_for_third_party_applications:
'Only third-party applications can manage user consent scopes.',
third_party_application_only: 'The feature is only available for third-party applications.',
/** UNTRANSLATED */
user_consent_scopes_not_found: 'Invalid user consent scopes.',
};

View file

@ -6,8 +6,7 @@ const application = {
invalid_third_party_application_type:
'Only traditional web applications can be marked as a third-party app.',
/** UNTRANSLATED */
user_consent_scopes_only_for_third_party_applications:
'Only third-party applications can manage user consent scopes.',
third_party_application_only: 'The feature is only available for third-party applications.',
/** UNTRANSLATED */
user_consent_scopes_not_found: 'Invalid user consent scopes.',
};

View file

@ -7,8 +7,7 @@ const application = {
invalid_third_party_application_type:
'Only traditional web applications can be marked as a third-party app.',
/** UNTRANSLATED */
user_consent_scopes_only_for_third_party_applications:
'Only third-party applications can manage user consent scopes.',
third_party_application_only: 'The feature is only available for third-party applications.',
/** UNTRANSLATED */
user_consent_scopes_not_found: 'Invalid user consent scopes.',
};

View file

@ -7,8 +7,7 @@ const application = {
invalid_third_party_application_type:
'Only traditional web applications can be marked as a third-party app.',
/** UNTRANSLATED */
user_consent_scopes_only_for_third_party_applications:
'Only third-party applications can manage user consent scopes.',
third_party_application_only: 'The feature is only available for third-party applications.',
/** UNTRANSLATED */
user_consent_scopes_not_found: 'Invalid user consent scopes.',
};

View file

@ -7,8 +7,7 @@ const application = {
invalid_third_party_application_type:
'Only traditional web applications can be marked as a third-party app.',
/** UNTRANSLATED */
user_consent_scopes_only_for_third_party_applications:
'Only third-party applications can manage user consent scopes.',
third_party_application_only: 'The feature is only available for third-party applications.',
/** UNTRANSLATED */
user_consent_scopes_not_found: 'Invalid user consent scopes.',
};

View file

@ -6,8 +6,7 @@ const application = {
invalid_third_party_application_type:
'Only traditional web applications can be marked as a third-party app.',
/** UNTRANSLATED */
user_consent_scopes_only_for_third_party_applications:
'Only third-party applications can manage user consent scopes.',
third_party_application_only: 'The feature is only available for third-party applications.',
/** UNTRANSLATED */
user_consent_scopes_not_found: 'Invalid user consent scopes.',
};

View file

@ -6,8 +6,7 @@ const application = {
invalid_third_party_application_type:
'Only traditional web applications can be marked as a third-party app.',
/** UNTRANSLATED */
user_consent_scopes_only_for_third_party_applications:
'Only third-party applications can manage user consent scopes.',
third_party_application_only: 'The feature is only available for third-party applications.',
/** UNTRANSLATED */
user_consent_scopes_not_found: 'Invalid user consent scopes.',
};

View file

@ -6,8 +6,7 @@ const application = {
invalid_third_party_application_type:
'Only traditional web applications can be marked as a third-party app.',
/** UNTRANSLATED */
user_consent_scopes_only_for_third_party_applications:
'Only third-party applications can manage user consent scopes.',
third_party_application_only: 'The feature is only available for third-party applications.',
/** UNTRANSLATED */
user_consent_scopes_not_found: 'Invalid user consent scopes.',
};

View file

@ -6,8 +6,7 @@ const application = {
invalid_third_party_application_type:
'Only traditional web applications can be marked as a third-party app.',
/** UNTRANSLATED */
user_consent_scopes_only_for_third_party_applications:
'Only third-party applications can manage user consent scopes.',
third_party_application_only: 'The feature is only available for third-party applications.',
/** UNTRANSLATED */
user_consent_scopes_not_found: 'Invalid user consent scopes.',
};

View file

@ -7,6 +7,7 @@ import {
OrganizationScopes,
Resources,
Scopes,
ApplicationSignInExperiences,
} from '../db-entries/index.js';
export type ApplicationResponse = Application & { isAdmin: boolean };
@ -61,3 +62,22 @@ export enum ApplicationUserConsentScopeType {
export type ApplicationUserConsentScopesResponse = z.infer<
typeof applicationUserConsentScopesResponseGuard
>;
export const applicationSignInExperienceCreateGuard = ApplicationSignInExperiences.createGuard
.omit({
applicationId: true,
tenantId: true,
termsOfUseUrl: true,
privacyPolicyUrl: true,
})
// Align with the sign-in-experience create guard.
.merge(
z.object({
termsOfUseUrl: z.string().max(2048).url().optional().nullable().or(z.literal('')),
privacyPolicyUrl: z.string().max(2048).url().optional().nullable().or(z.literal('')),
})
);
export type ApplicationSignInExperienceCreate = z.infer<
typeof applicationSignInExperienceCreateGuard
>;