Skip to content

Commit

Permalink
feat(core): implement enterprise sso verification flow (#6198)
Browse files Browse the repository at this point in the history
implement the enterprise sso verification flow
  • Loading branch information
simeng-li authored Jul 9, 2024
1 parent d7fa9f5 commit addb528
Show file tree
Hide file tree
Showing 10 changed files with 611 additions and 9 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
}
18 changes: 16 additions & 2 deletions packages/core/src/routes/experience/classes/verifications/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -23,7 +28,8 @@ import {
export type VerificationRecordData =
| PasswordVerificationRecordData
| CodeVerificationRecordData
| SocialVerificationRecordData;
| SocialVerificationRecordData
| EnterpriseSsoVerificationRecordData;

/**
* Union type for all verification record types
Expand All @@ -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,
]);

/**
Expand All @@ -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);
}
}
};
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/routes/experience/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -82,4 +83,5 @@ export default function experienceApiRoutes<T extends AnonymousRouter>(
passwordVerificationRoutes(router, tenant);
verificationCodeRoutes(router, tenant);
socialVerificationRoutes(router, tenant);
enterpriseSsoVerificationRoutes(router, tenant);
}
Loading

0 comments on commit addb528

Please sign in to comment.