Skip to content

Commit

Permalink
feat(core,schemas): implement social verification experience API endp…
Browse files Browse the repository at this point in the history
…oints (#6150)

feat(core,schemas): implement the social verification flow

implement the social verificaiton flow
  • Loading branch information
simeng-li authored Jul 5, 2024
1 parent af44e87 commit d16bc9b
Show file tree
Hide file tree
Showing 11 changed files with 633 additions and 6 deletions.
31 changes: 28 additions & 3 deletions packages/connectors/connector-mock-social/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ import { randomUUID } from 'node:crypto';
import { z } from 'zod';

import type {
CreateConnector,
GetAuthorizationUri,
GetUserInfo,
CreateConnector,
SocialConnector,
} from '@logto/connector-kit';
import {
Expand All @@ -17,11 +17,23 @@ import {
import { defaultMetadata } from './constant.js';
import { mockSocialConfigGuard } from './types.js';

const getAuthorizationUri: GetAuthorizationUri = async ({ state, redirectUri }) => {
const getAuthorizationUri: GetAuthorizationUri = async (
{ state, redirectUri, connectorId },
setSession
) => {
try {
await setSession({ state, redirectUri, connectorId });
} catch (error: unknown) {
// Ignore the error if the method is not implemented
if (!(error instanceof ConnectorError && error.code === ConnectorErrorCodes.NotImplemented)) {
throw error;
}
}

return `http://mock.social.com/?state=${state}&redirect_uri=${redirectUri}`;
};

const getUserInfo: GetUserInfo = async (data) => {
const getUserInfo: GetUserInfo = async (data, getSession) => {
const dataGuard = z.object({
code: z.string(),
userId: z.optional(z.string()),
Expand All @@ -34,6 +46,19 @@ const getUserInfo: GetUserInfo = async (data) => {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, JSON.stringify(data));
}

try {
const connectorSession = await getSession();
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!connectorSession) {
throw new ConnectorError(ConnectorErrorCodes.AuthorizationFailed);
}
} catch (error: unknown) {
// Ignore the error if the method is not implemented
if (!(error instanceof ConnectorError && error.code === ConnectorErrorCodes.NotImplemented)) {
throw error;
}
}

const { code, userId, ...rest } = result.data;

// For mock use only. Use to track the created user entity
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ export default class ExperienceInteraction {
);

// Throws an 404 error if the user is not found by the given verification record
// TODO: refactor using real-time user verification. Static verifiedUserId will be removed.
assertThat(
verificationRecord.verifiedUserId,
new RequestError({
Expand Down
16 changes: 14 additions & 2 deletions packages/core/src/routes/experience/classes/verifications/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,16 @@ import {
passwordVerificationRecordDataGuard,
type PasswordVerificationRecordData,
} from './password-verification.js';
import {
SocialVerification,
socialVerificationRecordDataGuard,
type SocialVerificationRecordData,
} from './social-verification.js';

type VerificationRecordData = PasswordVerificationRecordData | CodeVerificationRecordData;
type VerificationRecordData =
| PasswordVerificationRecordData
| CodeVerificationRecordData
| SocialVerificationRecordData;

/**
* Union type for all verification record types
Expand All @@ -25,11 +33,12 @@ type VerificationRecordData = PasswordVerificationRecordData | CodeVerificationR
* 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;
export type VerificationRecord = PasswordVerification | CodeVerification | SocialVerification;

export const verificationRecordDataGuard = z.discriminatedUnion('type', [
passwordVerificationRecordDataGuard,
codeVerificationRecordDataGuard,
socialVerificationRecordDataGuard,
]);

/**
Expand All @@ -47,5 +56,8 @@ export const buildVerificationRecord = (
case VerificationType.VerificationCode: {
return new CodeVerification(libraries, queries, data);
}
case VerificationType.Social: {
return new SocialVerification(libraries, queries, data);
}
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import { socialUserInfoGuard, type SocialUserInfo, type ToZodObject } from '@logto/connector-kit';
import {
VerificationType,
type JsonObject,
type SocialAuthorizationUrlPayload,
type User,
} from '@logto/schemas';
import { generateStandardId } from '@logto/shared';
import { z } from 'zod';

import { type WithLogContext } from '#src/middleware/koa-audit-log.js';
import {
createSocialAuthorizationUrl,
verifySocialIdentity,
} from '#src/routes/interaction/utils/social-verification.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 { type VerificationRecord } from './verification-record.js';

/** The JSON data type for the SocialVerification record stored in the interaction storage */
export type SocialVerificationRecordData = {
id: string;
connectorId: string;
type: VerificationType.Social;
/**
* The social identity returned by the connector.
*/
socialUserInfo?: SocialUserInfo;
userId?: string;
};

export const socialVerificationRecordDataGuard = z.object({
id: z.string(),
connectorId: z.string(),
type: z.literal(VerificationType.Social),
socialUserInfo: socialUserInfoGuard.optional(),
userId: z.string().optional(),
}) satisfies ToZodObject<SocialVerificationRecordData>;

export class SocialVerification implements VerificationRecord<VerificationType.Social> {
/**
* Factory method to create a new SocialVerification instance
*/
static create(libraries: Libraries, queries: Queries, connectorId: string) {
return new SocialVerification(libraries, queries, {
id: generateStandardId(),
connectorId,
type: VerificationType.Social,
});
}

public readonly id: string;
public readonly type = VerificationType.Social;
public readonly connectorId: string;
public socialUserInfo?: SocialUserInfo;

/**
* The userId of the user that has been verified by the social identity.
* @deprecated will be removed in the coming PR
*/
public userId?: string;

constructor(
private readonly libraries: Libraries,
private readonly queries: Queries,
data: SocialVerificationRecordData
) {
const { id, connectorId, socialUserInfo, userId } =
socialVerificationRecordDataGuard.parse(data);

this.id = id;
this.connectorId = connectorId;
this.socialUserInfo = socialUserInfo;
this.userId = userId;
}

/**
* Returns true if the social identity has been verified
*/
get isVerified() {
return Boolean(this.socialUserInfo);
}

get verifiedUserId() {
return this.userId;
}

/**
* Create the authorization URL for the social connector.
* Store the connector session result in the provider's interaction storage.
*
* @remarks
* Refers to the {@link createSocialAuthorizationUrl} method in the interaction/utils/social-verification.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 createSocialAuthorizationUrl} 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 SocialVerification class.
*
* TODO: Consider store the connector session result in the verification record directly.
* SAML ACS endpoint will find the verification record by the jti and assign the connector session result to the verification record.
*/
async createAuthorizationUrl(
ctx: WithLogContext,
tenantContext: TenantContext,
{ state, redirectUri }: SocialAuthorizationUrlPayload
) {
return createSocialAuthorizationUrl(ctx, tenantContext, {
connectorId: this.connectorId,
state,
redirectUri,
});
}

/**
* 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.
* See the above {@link createAuthorizationUrl} method for more details.
*
* TODO: check the log event
*/
async verify(ctx: WithLogContext, tenantContext: TenantContext, connectorData: JsonObject) {
const socialUserInfo = await verifySocialIdentity(
{ connectorId: this.connectorId, connectorData },
ctx,
tenantContext
);

this.socialUserInfo = socialUserInfo;

const user = await this.findUserBySocialIdentity();
this.userId = user?.id;
}

async findUserBySocialIdentity(): Promise<User | undefined> {
const { socials } = this.libraries;
const {
users: { findUserByIdentity },
} = this.queries;

if (!this.socialUserInfo) {
return;
}

const {
metadata: { target },
} = await socials.getConnector(this.connectorId);

const user = await findUserByIdentity(target, this.socialUserInfo.id);

return user ?? undefined;
}

/**
* Find the related user using the social identity's verified email or phone number.
*/
async findRelatedUserBySocialIdentity(): ReturnType<typeof socials.findSocialRelatedUser> {
const { socials } = this.libraries;

if (!this.socialUserInfo) {
return null;
}

return socials.findSocialRelatedUser(this.socialUserInfo);
}

toJson(): SocialVerificationRecordData {
const { id, connectorId, socialUserInfo, type } = this;

return {
id,
connectorId,
type,
socialUserInfo,
};
}
}
6 changes: 6 additions & 0 deletions packages/core/src/routes/experience/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import koaExperienceInteraction, {
type WithExperienceInteractionContext,
} from './middleware/koa-experience-interaction.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';

type RouterContext<T> = T extends Router<unknown, infer Context> ? Context : never;
Expand Down Expand Up @@ -52,6 +53,10 @@ export default function experienceApiRoutes<T extends AnonymousRouter>(

ctx.experienceInteraction.setInteractionEvent(interactionEvent);

// TODO: SIE verification method check
// TODO: forgot password verification method check, only allow email and phone verification code
// TODO: user suspension check

ctx.experienceInteraction.identifyUser(verificationId);

await ctx.experienceInteraction.save();
Expand Down Expand Up @@ -79,4 +84,5 @@ export default function experienceApiRoutes<T extends AnonymousRouter>(

passwordVerificationRoutes(router, tenant);
verificationCodeRoutes(router, tenant);
socialVerificationRoutes(router, tenant);
}
Loading

0 comments on commit d16bc9b

Please sign in to comment.