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

feat(core): implement enterprise sso verification flow (#6198)

implement the enterprise sso verification flow
This commit is contained in:
simeng-li 2024-07-09 14:59:52 +08:00 committed by GitHub
parent d7fa9f5900
commit addb528652
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 611 additions and 9 deletions

View file

@ -59,7 +59,7 @@ export default class ExperienceInteraction {
constructor(
private readonly ctx: WithLogContext,
private readonly tenant: TenantContext,
interactionDetails: Interaction
public interactionDetails: Interaction
) {
const { libraries, queries } = tenant;
@ -125,8 +125,12 @@ export default class ExperienceInteraction {
switch (verificationRecord.type) {
case VerificationType.Password:
case VerificationType.VerificationCode:
case VerificationType.Social: {
case VerificationType.Social:
case VerificationType.EnterpriseSso: {
// TODO: social sign-in with verified email
const { id, isSuspended } = await verificationRecord.identifyUser();
assertThat(!isSuspended, new RequestError({ code: 'user.suspended', status: 401 }));
// Throws an 409 error if the current session has already identified a different user

View file

@ -0,0 +1,208 @@
import { socialUserInfoGuard, type SocialUserInfo, type ToZodObject } from '@logto/connector-kit';
import {
VerificationType,
type JsonObject,
type SocialAuthorizationUrlPayload,
type SupportedSsoConnector,
type User,
type UserSsoIdentity,
} from '@logto/schemas';
import { generateStandardId } from '@logto/shared';
import { z } from 'zod';
import RequestError from '#src/errors/RequestError/index.js';
import { type WithLogContext } from '#src/middleware/koa-audit-log.js';
import {
getSsoAuthorizationUrl,
verifySsoIdentity,
} from '#src/routes/interaction/utils/single-sign-on.js';
import type Libraries from '#src/tenants/Libraries.js';
import type Queries from '#src/tenants/Queries.js';
import type TenantContext from '#src/tenants/TenantContext.js';
import assertThat from '#src/utils/assert-that.js';
import { type VerificationRecord } from './verification-record.js';
/** The JSON data type for the EnterpriseSsoVerification record stored in the interaction storage */
export type EnterpriseSsoVerificationRecordData = {
id: string;
connectorId: string;
type: VerificationType.EnterpriseSso;
/**
* The enterprise SSO identity returned by the connector.
*/
enterpriseSsoUserInfo?: SocialUserInfo;
issuer?: string;
};
export const enterPriseSsoVerificationRecordDataGuard = z.object({
id: z.string(),
connectorId: z.string(),
type: z.literal(VerificationType.EnterpriseSso),
enterpriseSsoUserInfo: socialUserInfoGuard.optional(),
issuer: z.string().optional(),
}) satisfies ToZodObject<EnterpriseSsoVerificationRecordData>;
export class EnterpriseSsoVerification
implements VerificationRecord<VerificationType.EnterpriseSso>
{
static create(libraries: Libraries, queries: Queries, connectorId: string) {
return new EnterpriseSsoVerification(libraries, queries, {
id: generateStandardId(),
connectorId,
type: VerificationType.EnterpriseSso,
});
}
public readonly id: string;
public readonly type = VerificationType.EnterpriseSso;
public readonly connectorId: string;
public enterpriseSsoUserInfo?: SocialUserInfo;
public issuer?: string;
private connectorDataCache?: SupportedSsoConnector;
constructor(
private readonly libraries: Libraries,
private readonly queries: Queries,
data: EnterpriseSsoVerificationRecordData
) {
const { id, connectorId, enterpriseSsoUserInfo, issuer } =
enterPriseSsoVerificationRecordDataGuard.parse(data);
this.id = id;
this.connectorId = connectorId;
this.enterpriseSsoUserInfo = enterpriseSsoUserInfo;
this.issuer = issuer;
}
/** Returns true if the enterprise SSO identity has been verified */
get isVerified() {
return Boolean(this.enterpriseSsoUserInfo && this.issuer);
}
async getConnectorData(connectorId: string) {
this.connectorDataCache ||= await this.libraries.ssoConnectors.getSsoConnectorById(connectorId);
return this.connectorDataCache;
}
/**
* Create the authorization URL for the enterprise SSO connector.
*
* @remarks
* Refers to thr {@link getSsoAuthorizationUrl} function in the interaction/utils/single-sign-on.ts file.
* Currently, all the intermediate connector session results are stored in the provider's interactionDetails separately,
* apart from the new verification record.
* For compatibility reasons, we keep using the old {@link getSsoAuthorizationUrl} method here as a single source of truth.
* Especially for the SAML connectors,
* SAML ACS endpoint will find the connector session result by the jti and assign it to the interaction storage.
* We will need to update the SAML ACS endpoint before move the logic to this new EnterpriseSsoVerification class.
*/
async createAuthorizationUrl(
ctx: WithLogContext,
tenantContext: TenantContext,
payload: SocialAuthorizationUrlPayload
) {
const connectorData = await this.getConnectorData(this.connectorId);
return getSsoAuthorizationUrl(ctx, tenantContext, connectorData, payload);
}
/**
* Verify the enterprise SSO identity and store the enterprise SSO identity in the verification record.
*
* @remarks
* Refers to the {@link verifySsoIdentity} function in the interaction/utils/single-sign-on.ts file.
* For compatibility reasons, we keep using the old {@link verifySsoIdentity} method here as a single source of truth.
* See the above {@link createAuthorizationUrl} method for more details.
*/
async verify(ctx: WithLogContext, tenantContext: TenantContext, callbackData: JsonObject) {
const connectorData = await this.getConnectorData(this.connectorId);
const { issuer, userInfo } = await verifySsoIdentity(
ctx,
tenantContext,
connectorData,
callbackData
);
this.issuer = issuer;
this.enterpriseSsoUserInfo = userInfo;
}
async identifyUser(): Promise<User> {
assertThat(
this.isVerified,
new RequestError({ code: 'session.verification_failed', status: 422 })
);
// TODO: sync userInfo and link sso identity
const userSsoIdentityResult = await this.findUserSsoIdentityByEnterpriseSsoUserInfo();
if (userSsoIdentityResult) {
return userSsoIdentityResult.user;
}
const relatedUser = await this.findRelatedUserSsoIdentity();
if (relatedUser) {
return relatedUser;
}
throw new RequestError({ code: 'user.identity_not_exist', status: 404 });
}
toJson(): EnterpriseSsoVerificationRecordData {
const { id, connectorId, type, enterpriseSsoUserInfo, issuer } = this;
return {
id,
connectorId,
type,
enterpriseSsoUserInfo,
issuer,
};
}
private async findUserSsoIdentityByEnterpriseSsoUserInfo(): Promise<
| {
user: User;
userSsoIdentity: UserSsoIdentity;
}
| undefined
> {
const { userSsoIdentities: userSsoIdentitiesQueries, users: usersQueries } = this.queries;
if (!this.issuer || !this.enterpriseSsoUserInfo) {
return;
}
const userSsoIdentity = await userSsoIdentitiesQueries.findUserSsoIdentityBySsoIdentityId(
this.issuer,
this.enterpriseSsoUserInfo.id
);
if (userSsoIdentity) {
const user = await usersQueries.findUserById(userSsoIdentity.userId);
return {
user,
userSsoIdentity,
};
}
}
/**
* Find the related user by the enterprise SSO identity's verified email.
*/
private async findRelatedUserSsoIdentity(): Promise<User | undefined> {
const { users: usersQueries } = this.queries;
if (!this.enterpriseSsoUserInfo?.email) {
return;
}
const user = await usersQueries.findUserByEmail(this.enterpriseSsoUserInfo.email);
return user ?? undefined;
}
}

View file

@ -9,6 +9,11 @@ import {
codeVerificationRecordDataGuard,
type CodeVerificationRecordData,
} from './code-verification.js';
import {
EnterpriseSsoVerification,
enterPriseSsoVerificationRecordDataGuard,
type EnterpriseSsoVerificationRecordData,
} from './enterprise-sso-verification.js';
import {
PasswordVerification,
passwordVerificationRecordDataGuard,
@ -23,7 +28,8 @@ import {
export type VerificationRecordData =
| PasswordVerificationRecordData
| CodeVerificationRecordData
| SocialVerificationRecordData;
| SocialVerificationRecordData
| EnterpriseSsoVerificationRecordData;
/**
* Union type for all verification record types
@ -33,12 +39,17 @@ export type VerificationRecordData =
* This union type is used to narrow down the type of the verification record.
* Used in the ExperienceInteraction class to store and manage all the verification records. With a given verification type, we can narrow down the type of the verification record.
*/
export type VerificationRecord = PasswordVerification | CodeVerification | SocialVerification;
export type VerificationRecord =
| PasswordVerification
| CodeVerification
| SocialVerification
| EnterpriseSsoVerification;
export const verificationRecordDataGuard = z.discriminatedUnion('type', [
passwordVerificationRecordDataGuard,
codeVerificationRecordDataGuard,
socialVerificationRecordDataGuard,
enterPriseSsoVerificationRecordDataGuard,
]);
/**
@ -59,5 +70,8 @@ export const buildVerificationRecord = (
case VerificationType.Social: {
return new SocialVerification(libraries, queries, data);
}
case VerificationType.EnterpriseSso: {
return new EnterpriseSsoVerification(libraries, queries, data);
}
}
};

View file

@ -106,9 +106,6 @@ export class SocialVerification implements VerificationRecord<VerificationType.S
/**
* Verify the social identity and store the social identity in the verification record.
*
* - Store the social identity in the verification record.
* - Find the user by the social identity and store the userId in the verification record if the user exists.
*
* @remarks
* Refer to the {@link verifySocialIdentity} method in the interaction/utils/social-verification.ts file.
* For compatibility reasons, we keep using the old {@link verifySocialIdentity} method here as a single source of truth.
@ -133,9 +130,11 @@ export class SocialVerification implements VerificationRecord<VerificationType.S
async identifyUser(): Promise<User> {
assertThat(
this.isVerified,
new RequestError({ code: 'session.verification_failed', status: 400 })
new RequestError({ code: 'session.verification_failed', status: 422 })
);
// TODO: sync userInfo and link social identity
const user = await this.findUserBySocialIdentity();
if (!user) {

View file

@ -23,6 +23,7 @@ import { experienceRoutes } from './const.js';
import koaExperienceInteraction, {
type WithExperienceInteractionContext,
} from './middleware/koa-experience-interaction.js';
import enterpriseSsoVerificationRoutes from './verification-routes/enterprise-sso-verification.js';
import passwordVerificationRoutes from './verification-routes/password-verification.js';
import socialVerificationRoutes from './verification-routes/social-verification.js';
import verificationCodeRoutes from './verification-routes/verification-code.js';
@ -82,4 +83,5 @@ export default function experienceApiRoutes<T extends AnonymousRouter>(
passwordVerificationRoutes(router, tenant);
verificationCodeRoutes(router, tenant);
socialVerificationRoutes(router, tenant);
enterpriseSsoVerificationRoutes(router, tenant);
}

View file

@ -0,0 +1,103 @@
import {
VerificationType,
socialAuthorizationUrlPayloadGuard,
socialVerificationCallbackPayloadGuard,
} from '@logto/schemas';
import type Router from 'koa-router';
import { z } from 'zod';
import RequestError from '#src/errors/RequestError/index.js';
import { type WithLogContext } from '#src/middleware/koa-audit-log.js';
import koaGuard from '#src/middleware/koa-guard.js';
import type TenantContext from '#src/tenants/TenantContext.js';
import assertThat from '#src/utils/assert-that.js';
import { EnterpriseSsoVerification } from '../classes/verifications/enterprise-sso-verification.js';
import { experienceRoutes } from '../const.js';
import { type WithExperienceInteractionContext } from '../middleware/koa-experience-interaction.js';
export default function enterpriseSsoVerificationRoutes<T extends WithLogContext>(
router: Router<unknown, WithExperienceInteractionContext<T>>,
tenantContext: TenantContext
) {
const { libraries, queries } = tenantContext;
router.post(
`${experienceRoutes.verification}/sso/:connectorId/authorization-uri`,
koaGuard({
params: z.object({
connectorId: z.string(),
}),
body: socialAuthorizationUrlPayloadGuard,
response: z.object({
authorizationUri: z.string(),
verificationId: z.string(),
}),
status: [200, 400, 404, 500],
}),
async (ctx, next) => {
const { connectorId } = ctx.guard.params;
const enterpriseSsoVerification = EnterpriseSsoVerification.create(
libraries,
queries,
connectorId
);
const authorizationUri = await enterpriseSsoVerification.createAuthorizationUrl(
ctx,
tenantContext,
ctx.guard.body
);
ctx.experienceInteraction.setVerificationRecord(enterpriseSsoVerification);
await ctx.experienceInteraction.save();
ctx.body = {
authorizationUri,
verificationId: enterpriseSsoVerification.id,
};
return next();
}
);
router.post(
`${experienceRoutes.verification}/sso/:connectorId/verify`,
koaGuard({
params: z.object({
connectorId: z.string(),
}),
body: socialVerificationCallbackPayloadGuard,
response: z.object({
verificationId: z.string(),
}),
status: [200, 400, 404, 500],
}),
async (ctx, next) => {
const { connectorId } = ctx.params;
const { connectorData, verificationId } = ctx.guard.body;
const enterpriseSsoVerificationRecord =
ctx.experienceInteraction.getVerificationRecordById(verificationId);
assertThat(
enterpriseSsoVerificationRecord &&
enterpriseSsoVerificationRecord.type === VerificationType.EnterpriseSso &&
enterpriseSsoVerificationRecord.connectorId === connectorId,
new RequestError({ code: 'session.verification_session_not_found', status: 404 })
);
await enterpriseSsoVerificationRecord.verify(ctx, tenantContext, connectorData);
await ctx.experienceInteraction.save();
ctx.body = {
verificationId,
};
return next();
}
);
}

View file

@ -88,7 +88,7 @@ type SsoAuthenticationResult = {
*
* @returns The SSO authentication result
*/
const verifySsoIdentity = async (
export const verifySsoIdentity = async (
ctx: WithLogContext,
{ provider, id: tenantId }: TenantContext,
connectorData: SupportedSsoConnector,

View file

@ -102,4 +102,34 @@ export class ExperienceClient extends MockClient {
})
.json<{ verificationId: string }>();
}
public async getEnterpriseSsoAuthorizationUri(
connectorId: string,
payload: {
redirectUri: string;
state: string;
}
) {
return api
.post(`${experienceRoutes.verification}/sso/${connectorId}/authorization-uri`, {
headers: { cookie: this.interactionCookie },
json: payload,
})
.json<{ authorizationUri: string; verificationId: string }>();
}
public async verifyEnterpriseSsoAuthorization(
connectorId: string,
payload: {
verificationId: string;
connectorData: Record<string, unknown>;
}
) {
return api
.post(`${experienceRoutes.verification}/sso/${connectorId}/verify`, {
headers: { cookie: this.interactionCookie },
json: payload,
})
.json<{ verificationId: string }>();
}
}

View file

@ -0,0 +1,41 @@
import { type ExperienceClient } from '#src/client/experience/index.js';
export const successFullyCreateEnterpriseSsoVerification = async (
client: ExperienceClient,
connectorId: string,
payload: {
redirectUri: string;
state: string;
}
) => {
const { authorizationUri, verificationId } = await client.getEnterpriseSsoAuthorizationUri(
connectorId,
payload
);
expect(verificationId).toBeTruthy();
expect(authorizationUri).toBeTruthy();
return {
verificationId,
authorizationUri,
};
};
export const successFullyVerifyEnterpriseSsoAuthorization = async (
client: ExperienceClient,
connectorId: string,
payload: {
verificationId: string;
connectorData: Record<string, unknown>;
}
) => {
const { verificationId: verifiedVerificationId } = await client.verifyEnterpriseSsoAuthorization(
connectorId,
payload
);
expect(verifiedVerificationId).toBeTruthy();
return verifiedVerificationId;
};

View file

@ -0,0 +1,201 @@
import { ConnectorType } from '@logto/connector-kit';
import { mockSocialConnectorId } from '#src/__mocks__/connectors-mock.js';
import { updateSignInExperience } from '#src/api/sign-in-experience.js';
import { SsoConnectorApi } from '#src/api/sso-connector.js';
import { initExperienceClient } from '#src/helpers/client.js';
import { clearConnectorsByTypes, setSocialConnector } from '#src/helpers/connector.js';
import {
successFullyCreateEnterpriseSsoVerification,
successFullyVerifyEnterpriseSsoAuthorization,
} from '#src/helpers/experience/enterprise-sso-verification.js';
import { successFullyCreateSocialVerification } from '#src/helpers/experience/social-verification.js';
import { expectRejects } from '#src/helpers/index.js';
import { devFeatureTest, generateUserId, randomString } from '#src/utils.js';
devFeatureTest.describe('enterprise sso verification', () => {
const state = 'fake_state';
const redirectUri = 'http://localhost:3000/redirect';
const authorizationCode = 'fake_code';
const domain = `foo${randomString()}.com`;
const ssoConnectorApi = new SsoConnectorApi();
const socialConnectorIdMap = new Map<string, string>();
beforeAll(async () => {
await clearConnectorsByTypes([ConnectorType.Social]);
await ssoConnectorApi.createMockOidcConnector([domain]);
const { id: socialConnectorId } = await setSocialConnector();
socialConnectorIdMap.set(mockSocialConnectorId, socialConnectorId);
// Make sure single sign on is enabled
await updateSignInExperience({
singleSignOnEnabled: true,
});
});
afterAll(async () => {
await ssoConnectorApi.cleanUp();
await clearConnectorsByTypes([ConnectorType.Social]);
});
describe('getSsoAuthorizationUri', () => {
it('should throw if the state or redirectUri is empty', async () => {
const client = await initExperienceClient();
const connectorId = ssoConnectorApi.firstConnectorId!;
await expectRejects(
client.getSocialAuthorizationUri(connectorId, {
redirectUri,
state: '',
}),
{
code: 'session.insufficient_info',
status: 400,
}
);
await expectRejects(
client.getSocialAuthorizationUri(connectorId, {
redirectUri: '',
state,
}),
{
code: 'session.insufficient_info',
status: 400,
}
);
});
it('should throw if the connector is not found', async () => {
const client = await initExperienceClient();
return expectRejects(
client.getEnterpriseSsoAuthorizationUri('invalid_connector_id', {
redirectUri,
state,
}),
{
code: 'entity.not_exists_with_id',
status: 404,
}
);
});
it('should return the authorization uri', async () => {
const client = await initExperienceClient();
const connectorId = ssoConnectorApi.firstConnectorId!;
const { authorizationUri, verificationId } = await client.getEnterpriseSsoAuthorizationUri(
connectorId,
{
redirectUri,
state,
}
);
expect(verificationId).toBeTruthy();
expect(authorizationUri).toBeTruthy();
});
});
describe('verifyEnterpriseSsoAuthorization', () => {
it('should throw if the verification record is not found', async () => {
const client = await initExperienceClient();
const connectorId = ssoConnectorApi.firstConnectorId!;
await successFullyCreateEnterpriseSsoVerification(client, connectorId, {
redirectUri,
state,
});
await expectRejects(
client.verifyEnterpriseSsoAuthorization(connectorId, {
verificationId: 'invalid_verification_id',
connectorData: {
authorizationCode,
},
}),
{
code: 'session.verification_session_not_found',
status: 404,
}
);
});
it('should throw if the verification type is not enterprise sso', async () => {
const client = await initExperienceClient();
const connectorId = socialConnectorIdMap.get(mockSocialConnectorId)!;
const { verificationId } = await successFullyCreateSocialVerification(client, connectorId, {
redirectUri,
state,
});
await expectRejects(
client.verifyEnterpriseSsoAuthorization(connectorId, {
verificationId,
connectorData: {
authorizationCode,
},
}),
{
code: 'session.verification_session_not_found',
status: 404,
}
);
});
it('should throw if the connectorId does not match', async () => {
const client = await initExperienceClient();
const connectorId = ssoConnectorApi.firstConnectorId!;
const { verificationId } = await successFullyCreateEnterpriseSsoVerification(
client,
connectorId,
{
redirectUri,
state,
}
);
await expectRejects(
client.verifyEnterpriseSsoAuthorization('invalid_connector_id', {
verificationId,
connectorData: {
authorizationCode,
},
}),
{
code: 'session.verification_session_not_found',
status: 404,
}
);
});
it('should successfully verify the authorization', async () => {
const client = await initExperienceClient();
const connectorId = ssoConnectorApi.firstConnectorId!;
const { verificationId } = await successFullyCreateEnterpriseSsoVerification(
client,
connectorId,
{
redirectUri,
state,
}
);
// Pass the sub value as a callback connectorData to mock the SsoConnector.getUserInfo return value.
const fakeSsoIdentitySub = generateUserId();
await successFullyVerifyEnterpriseSsoAuthorization(client, connectorId, {
verificationId,
connectorData: {
authorizationCode,
sub: fakeSsoIdentitySub,
},
});
});
});
});