mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
feat(core): add social identity (#6703)
* feat(core): add social identity * refactor(core): refactor social verification class (#6741) * refactor(core): refactor social verification class refactor social verification class * fix(core): remove unused method remove unused method * chore: rename to connectorSessionType --------- Co-authored-by: simeng-li <simeng@silverhand.io>
This commit is contained in:
parent
50031369a1
commit
9742f05ff6
10 changed files with 608 additions and 22 deletions
|
@ -29,6 +29,7 @@ export const createUserLibrary = (queries: Queries) => {
|
||||||
hasUserWithEmail,
|
hasUserWithEmail,
|
||||||
hasUserWithId,
|
hasUserWithId,
|
||||||
hasUserWithPhone,
|
hasUserWithPhone,
|
||||||
|
hasUserWithIdentity,
|
||||||
findUsersByIds,
|
findUsersByIds,
|
||||||
updateUserById,
|
updateUserById,
|
||||||
findUserById,
|
findUserById,
|
||||||
|
@ -91,10 +92,11 @@ export const createUserLibrary = (queries: Queries) => {
|
||||||
username?: Nullable<string>;
|
username?: Nullable<string>;
|
||||||
primaryEmail?: Nullable<string>;
|
primaryEmail?: Nullable<string>;
|
||||||
primaryPhone?: Nullable<string>;
|
primaryPhone?: Nullable<string>;
|
||||||
|
identity?: Nullable<{ target: string; id: string }>;
|
||||||
},
|
},
|
||||||
excludeUserId?: string
|
excludeUserId?: string
|
||||||
) => {
|
) => {
|
||||||
const { primaryEmail, primaryPhone, username } = identifiers;
|
const { primaryEmail, primaryPhone, username, identity } = identifiers;
|
||||||
|
|
||||||
if (primaryEmail && (await hasUserWithEmail(primaryEmail, excludeUserId))) {
|
if (primaryEmail && (await hasUserWithEmail(primaryEmail, excludeUserId))) {
|
||||||
throw new RequestError({ code: 'user.email_already_in_use', status: 422 });
|
throw new RequestError({ code: 'user.email_already_in_use', status: 422 });
|
||||||
|
@ -107,6 +109,10 @@ export const createUserLibrary = (queries: Queries) => {
|
||||||
if (username && (await hasUser(username, excludeUserId))) {
|
if (username && (await hasUser(username, excludeUserId))) {
|
||||||
throw new RequestError({ code: 'user.username_already_in_use', status: 422 });
|
throw new RequestError({ code: 'user.username_already_in_use', status: 422 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (identity && (await hasUserWithIdentity(identity.target, identity.id, excludeUserId))) {
|
||||||
|
throw new RequestError({ code: 'user.identity_already_in_use', status: 422 });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const findUsersByRoleName = async (roleName: string) => {
|
const findUsersByRoleName = async (roleName: string) => {
|
||||||
|
|
|
@ -1,4 +1,13 @@
|
||||||
import { socialUserInfoGuard, type SocialUserInfo, type ToZodObject } from '@logto/connector-kit';
|
import {
|
||||||
|
type ConnectorSession,
|
||||||
|
connectorSessionGuard,
|
||||||
|
socialUserInfoGuard,
|
||||||
|
type SocialUserInfo,
|
||||||
|
type ToZodObject,
|
||||||
|
ConnectorType,
|
||||||
|
type SocialConnector,
|
||||||
|
GoogleConnector,
|
||||||
|
} from '@logto/connector-kit';
|
||||||
import {
|
import {
|
||||||
VerificationType,
|
VerificationType,
|
||||||
type JsonObject,
|
type JsonObject,
|
||||||
|
@ -34,6 +43,10 @@ export type SocialVerificationRecordData = {
|
||||||
* The social identity returned by the connector.
|
* The social identity returned by the connector.
|
||||||
*/
|
*/
|
||||||
socialUserInfo?: SocialUserInfo;
|
socialUserInfo?: SocialUserInfo;
|
||||||
|
/**
|
||||||
|
* The connector session result
|
||||||
|
*/
|
||||||
|
connectorSession?: ConnectorSession;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const socialVerificationRecordDataGuard = z.object({
|
export const socialVerificationRecordDataGuard = z.object({
|
||||||
|
@ -41,8 +54,11 @@ export const socialVerificationRecordDataGuard = z.object({
|
||||||
connectorId: z.string(),
|
connectorId: z.string(),
|
||||||
type: z.literal(VerificationType.Social),
|
type: z.literal(VerificationType.Social),
|
||||||
socialUserInfo: socialUserInfoGuard.optional(),
|
socialUserInfo: socialUserInfoGuard.optional(),
|
||||||
|
connectorSession: connectorSessionGuard.optional(),
|
||||||
}) satisfies ToZodObject<SocialVerificationRecordData>;
|
}) satisfies ToZodObject<SocialVerificationRecordData>;
|
||||||
|
|
||||||
|
type SocialAuthorizationSessionStorageType = 'interactionSession' | 'verificationRecord';
|
||||||
|
|
||||||
export class SocialVerification implements IdentifierVerificationRecord<VerificationType.Social> {
|
export class SocialVerification implements IdentifierVerificationRecord<VerificationType.Social> {
|
||||||
/**
|
/**
|
||||||
* Factory method to create a new SocialVerification instance
|
* Factory method to create a new SocialVerification instance
|
||||||
|
@ -59,7 +75,7 @@ export class SocialVerification implements IdentifierVerificationRecord<Verifica
|
||||||
public readonly type = VerificationType.Social;
|
public readonly type = VerificationType.Social;
|
||||||
public readonly connectorId: string;
|
public readonly connectorId: string;
|
||||||
public socialUserInfo?: SocialUserInfo;
|
public socialUserInfo?: SocialUserInfo;
|
||||||
|
public connectorSession?: ConnectorSession;
|
||||||
private connectorDataCache?: LogtoConnector;
|
private connectorDataCache?: LogtoConnector;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
@ -67,11 +83,13 @@ export class SocialVerification implements IdentifierVerificationRecord<Verifica
|
||||||
private readonly queries: Queries,
|
private readonly queries: Queries,
|
||||||
data: SocialVerificationRecordData
|
data: SocialVerificationRecordData
|
||||||
) {
|
) {
|
||||||
const { id, connectorId, socialUserInfo } = socialVerificationRecordDataGuard.parse(data);
|
const { id, connectorId, socialUserInfo, connectorSession } =
|
||||||
|
socialVerificationRecordDataGuard.parse(data);
|
||||||
|
|
||||||
this.id = id;
|
this.id = id;
|
||||||
this.connectorId = connectorId;
|
this.connectorId = connectorId;
|
||||||
this.socialUserInfo = socialUserInfo;
|
this.socialUserInfo = socialUserInfo;
|
||||||
|
this.connectorSession = connectorSession;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -82,26 +100,40 @@ export class SocialVerification implements IdentifierVerificationRecord<Verifica
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create the authorization URL for the social connector.
|
* Create the authorization URL for the social connector and generate a connector authorization session.
|
||||||
* Store the connector session result in the provider's interaction storage.
|
*
|
||||||
|
* @param {SocialAuthorizationSessionStorageType} connectorSessionType - Whether to store the connector session result in the current verification record directly. Set to `true` for the profile API.
|
||||||
*
|
*
|
||||||
* @remarks
|
* @remarks
|
||||||
* Refers to the {@link createSocialAuthorizationUrl} method in the interaction/utils/social-verification.ts file.
|
* For the experience API:
|
||||||
* Currently, all the intermediate connector session results are stored in the provider's interactionDetails separately,
|
* This method directly calls the {@link createSocialAuthorizationUrl} method in the interaction/utils/social-verification.ts file.
|
||||||
* apart from the new verification record.
|
* 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.
|
* For compatibility reasons, we keep using the old {@link createSocialAuthorizationUrl} method here as a single source of truth.
|
||||||
* Especially for the SAML connectors,
|
* Especially for the SAML connectors,
|
||||||
* SAML ACS endpoint will find the connector session result by the jti and assign it to the interaction storage.
|
* 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.
|
* 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.
|
* For the profile API:
|
||||||
|
* This method calls the internal {@link createSocialAuthorizationSession} method to create a social authorization session.
|
||||||
|
* The connector session result is stored in the current verification record directly.
|
||||||
|
* The social verification flow does not rely on the OIDC interaction context.
|
||||||
|
*
|
||||||
|
* TODO: Remove the old {@link createSocialAuthorizationUrl} once the old SAML connectors are updated.
|
||||||
|
* Align using the new {@link createSocialAuthorizationSession} method for both experience and profile APIs.
|
||||||
* SAML ACS endpoint will find the verification record by the jti and assign the connector session result to the verification record.
|
* SAML ACS endpoint will find the verification record by the jti and assign the connector session result to the verification record.
|
||||||
*/
|
*/
|
||||||
async createAuthorizationUrl(
|
async createAuthorizationUrl(
|
||||||
ctx: WithLogContext,
|
ctx: WithLogContext,
|
||||||
tenantContext: TenantContext,
|
tenantContext: TenantContext,
|
||||||
{ state, redirectUri }: SocialAuthorizationUrlPayload
|
{ state, redirectUri }: SocialAuthorizationUrlPayload,
|
||||||
|
connectorSessionType: SocialAuthorizationSessionStorageType = 'interactionSession'
|
||||||
) {
|
) {
|
||||||
|
// For the profile API, connector session result is stored in the current verification record directly.
|
||||||
|
if (connectorSessionType === 'verificationRecord') {
|
||||||
|
return this.createSocialAuthorizationSession(ctx, { state, redirectUri });
|
||||||
|
}
|
||||||
|
|
||||||
|
// For the experience API, connector session result is stored in the provider's interactionDetails.
|
||||||
return createSocialAuthorizationUrl(ctx, tenantContext, {
|
return createSocialAuthorizationUrl(ctx, tenantContext, {
|
||||||
connectorId: this.connectorId,
|
connectorId: this.connectorId,
|
||||||
state,
|
state,
|
||||||
|
@ -112,19 +144,36 @@ export class SocialVerification implements IdentifierVerificationRecord<Verifica
|
||||||
/**
|
/**
|
||||||
* Verify the social identity and store the social identity in the verification record.
|
* Verify the social identity and store the social identity in the verification record.
|
||||||
*
|
*
|
||||||
|
* @param {SocialAuthorizationSessionStorageType} connectorSessionType - Whether to find the connector session result from the current verification record directly. Set to `true` for the profile API.
|
||||||
|
*
|
||||||
* @remarks
|
* @remarks
|
||||||
* Refer to the {@link verifySocialIdentity} method in the interaction/utils/social-verification.ts file.
|
* For the experience API:
|
||||||
|
* This method directly calls the {@link verifySocialIdentity} method in the interaction/utils/social-verification.ts file.
|
||||||
|
* Fetch the connector session result from the provider's interactionDetails and verify the social identity.
|
||||||
* For compatibility reasons, we keep using the old {@link verifySocialIdentity} method here as a single source of truth.
|
* 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.
|
* See the above {@link createAuthorizationUrl} method for more details.
|
||||||
*
|
*
|
||||||
* TODO: check the log event
|
* For the profile API:
|
||||||
|
* This method calls the internal {@link verifySocialIdentityInternally} method to verify the social identity.
|
||||||
|
* The connector session result is fetched from the current verification record directly.
|
||||||
|
*
|
||||||
*/
|
*/
|
||||||
async verify(ctx: WithLogContext, tenantContext: TenantContext, connectorData: JsonObject) {
|
async verify(
|
||||||
const socialUserInfo = await verifySocialIdentity(
|
ctx: WithLogContext,
|
||||||
{ connectorId: this.connectorId, connectorData },
|
tenantContext: TenantContext,
|
||||||
ctx,
|
connectorData: JsonObject,
|
||||||
tenantContext
|
connectorSessionType: SocialAuthorizationSessionStorageType = 'interactionSession'
|
||||||
);
|
) {
|
||||||
|
const socialUserInfo =
|
||||||
|
connectorSessionType === 'verificationRecord'
|
||||||
|
? // For the profile API, find the connector session result from the current verification record directly.
|
||||||
|
await this.verifySocialIdentityInternally(connectorData, ctx)
|
||||||
|
: // For the experience API, fetch the connector session result from the provider's interactionDetails.
|
||||||
|
await verifySocialIdentity(
|
||||||
|
{ connectorId: this.connectorId, connectorData },
|
||||||
|
ctx,
|
||||||
|
tenantContext
|
||||||
|
);
|
||||||
|
|
||||||
this.socialUserInfo = socialUserInfo;
|
this.socialUserInfo = socialUserInfo;
|
||||||
}
|
}
|
||||||
|
@ -235,13 +284,14 @@ export class SocialVerification implements IdentifierVerificationRecord<Verifica
|
||||||
}
|
}
|
||||||
|
|
||||||
toJson(): SocialVerificationRecordData {
|
toJson(): SocialVerificationRecordData {
|
||||||
const { id, connectorId, type, socialUserInfo } = this;
|
const { id, connectorId, type, socialUserInfo, connectorSession } = this;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
connectorId,
|
connectorId,
|
||||||
type,
|
type,
|
||||||
socialUserInfo,
|
socialUserInfo,
|
||||||
|
connectorSession,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -278,11 +328,85 @@ export class SocialVerification implements IdentifierVerificationRecord<Verifica
|
||||||
return socials.findSocialRelatedUser(this.socialUserInfo);
|
return socials.findSocialRelatedUser(this.socialUserInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getConnectorData() {
|
private async getConnectorData(): Promise<LogtoConnector<SocialConnector>> {
|
||||||
const { getConnector } = this.libraries.socials;
|
const { getConnector } = this.libraries.socials;
|
||||||
|
|
||||||
this.connectorDataCache ||= await getConnector(this.connectorId);
|
this.connectorDataCache ||= await getConnector(this.connectorId);
|
||||||
|
|
||||||
|
assertThat(this.connectorDataCache.type === ConnectorType.Social, 'connector.unexpected_type');
|
||||||
|
|
||||||
return this.connectorDataCache;
|
return this.connectorDataCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal method to create a social authorization session.
|
||||||
|
*
|
||||||
|
* @remarks
|
||||||
|
* This method is a alternative to the {@link createSocialAuthorizationUrl} method in the interaction/utils/social-verification.ts file.
|
||||||
|
* Generate the social authorization URL and store the connector session result in the current verification record directly.
|
||||||
|
* This social connector session result will be used to verify the social response later.
|
||||||
|
* This method can be used for both experience and profile APIs, w/o OIDC interaction context.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
private async createSocialAuthorizationSession(
|
||||||
|
ctx: WithLogContext,
|
||||||
|
{ state, redirectUri }: SocialAuthorizationUrlPayload
|
||||||
|
) {
|
||||||
|
assertThat(state && redirectUri, 'session.insufficient_info');
|
||||||
|
|
||||||
|
const connector = await this.getConnectorData();
|
||||||
|
|
||||||
|
const {
|
||||||
|
headers: { 'user-agent': userAgent },
|
||||||
|
} = ctx.request;
|
||||||
|
|
||||||
|
return connector.getAuthorizationUri(
|
||||||
|
{
|
||||||
|
state,
|
||||||
|
redirectUri,
|
||||||
|
connectorId: this.connectorId,
|
||||||
|
connectorFactoryId: connector.metadata.id,
|
||||||
|
// Instead of getting the jti from the interaction details, use the current verification record's id as the jti.
|
||||||
|
jti: this.id,
|
||||||
|
headers: { userAgent },
|
||||||
|
},
|
||||||
|
async (connectorSession) => {
|
||||||
|
// Store the connector session result in the current verification record directly.
|
||||||
|
this.connectorSession = connectorSession;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal method to verify the social identity.
|
||||||
|
*
|
||||||
|
* @remarks
|
||||||
|
* This method is a alternative to the {@link verifySocialIdentity} method in the interaction/utils/social-verification.ts file.
|
||||||
|
* Verify the social identity using the connector data received from the client and the connector session stored in the current verification record.
|
||||||
|
* This method can be used for both experience and profile APIs, w/o OIDC interaction context.
|
||||||
|
*/
|
||||||
|
private async verifySocialIdentityInternally(connectorData: JsonObject, ctx: WithLogContext) {
|
||||||
|
const connector = await this.getConnectorData();
|
||||||
|
|
||||||
|
// Verify the CSRF token if it's a Google connector and has credential (a Google One Tap verification)
|
||||||
|
if (
|
||||||
|
connector.metadata.id === GoogleConnector.factoryId &&
|
||||||
|
connectorData[GoogleConnector.oneTapParams.credential]
|
||||||
|
) {
|
||||||
|
const csrfToken = connectorData[GoogleConnector.oneTapParams.csrfToken];
|
||||||
|
const value = ctx.cookies.get(GoogleConnector.oneTapParams.csrfToken);
|
||||||
|
assertThat(value === csrfToken, 'session.csrf_token_mismatch');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the social authorization session exists
|
||||||
|
assertThat(this.connectorSession, 'session.connector_validation_session_not_found');
|
||||||
|
|
||||||
|
const socialUserInfo = await this.libraries.socials.getUserInfo(
|
||||||
|
this.connectorId,
|
||||||
|
connectorData,
|
||||||
|
async () => this.connectorSession ?? {}
|
||||||
|
);
|
||||||
|
|
||||||
|
return socialUserInfo;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -218,6 +218,34 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"/api/profile/identities": {
|
||||||
|
"post": {
|
||||||
|
"operationId": "AddUserIdentities",
|
||||||
|
"summary": "Add a user identity",
|
||||||
|
"description": "Add an identity (social identity) to the user, a verification record is required for checking sensitive permissions, and a verification record for the social identity is required.",
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"properties": {
|
||||||
|
"verificationRecordId": {
|
||||||
|
"description": "The verification record ID for checking sensitive permissions."
|
||||||
|
},
|
||||||
|
"newIdentifierVerificationRecordId": {
|
||||||
|
"description": "The identifier verification record ID for the new social identity ownership verification."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": "The identity was added successfully."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -236,4 +236,63 @@ export default function profileRoutes<T extends UserRouter>(
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/profile/identities',
|
||||||
|
koaGuard({
|
||||||
|
body: z.object({
|
||||||
|
verificationRecordId: z.string(),
|
||||||
|
newIdentifierVerificationRecordId: z.string(),
|
||||||
|
}),
|
||||||
|
status: [204, 400, 401],
|
||||||
|
}),
|
||||||
|
async (ctx, next) => {
|
||||||
|
const { id: userId, scopes } = ctx.auth;
|
||||||
|
const { verificationRecordId, newIdentifierVerificationRecordId } = ctx.guard.body;
|
||||||
|
|
||||||
|
assertThat(scopes.has(UserScope.Identities), 'auth.unauthorized');
|
||||||
|
|
||||||
|
await verifyUserSensitivePermission({
|
||||||
|
userId,
|
||||||
|
id: verificationRecordId,
|
||||||
|
queries,
|
||||||
|
libraries,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check new identifier
|
||||||
|
const newVerificationRecord = await buildVerificationRecordByIdAndType({
|
||||||
|
type: VerificationType.Social,
|
||||||
|
id: newIdentifierVerificationRecordId,
|
||||||
|
queries,
|
||||||
|
libraries,
|
||||||
|
});
|
||||||
|
assertThat(newVerificationRecord.isVerified, 'verification_record.not_found');
|
||||||
|
|
||||||
|
const {
|
||||||
|
socialIdentity: { target, userInfo },
|
||||||
|
} = await newVerificationRecord.toUserProfile();
|
||||||
|
|
||||||
|
await checkIdentifierCollision({ identity: { target, id: userInfo.id } }, userId);
|
||||||
|
|
||||||
|
const user = await findUserById(userId);
|
||||||
|
|
||||||
|
assertThat(!user.identities[target], 'user.identity_already_in_use');
|
||||||
|
|
||||||
|
const updatedUser = await updateUserById(userId, {
|
||||||
|
identities: {
|
||||||
|
...user.identities,
|
||||||
|
[target]: {
|
||||||
|
userId: userInfo.id,
|
||||||
|
details: userInfo,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.appendDataHookContext('User.Data.Updated', { user: updatedUser });
|
||||||
|
|
||||||
|
ctx.status = 204;
|
||||||
|
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -129,6 +129,88 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"/api/verifications/social": {
|
||||||
|
"post": {
|
||||||
|
"operationId": "CreateVerificationBySocial",
|
||||||
|
"summary": "Create a social verification record",
|
||||||
|
"description": "Create a social verification record and return the authorization URI.",
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"properties": {
|
||||||
|
"connectorId": {
|
||||||
|
"description": "The Logto connector ID."
|
||||||
|
},
|
||||||
|
"redirectUri": {
|
||||||
|
"description": "The URI to navigate back to after the user is authenticated by the connected social identity provider and has granted access to the connector."
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"description": "A random string generated on the client side to prevent CSRF (Cross-Site Request Forgery) attacks."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"201": {
|
||||||
|
"description": "Successfully created the social verification record and returned the authorization URI.",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"properties": {
|
||||||
|
"verificationRecordId": {
|
||||||
|
"description": "The ID of the verification record."
|
||||||
|
},
|
||||||
|
"authorizationUri": {
|
||||||
|
"description": "The authorization URI to navigate to for authentication and authorization in the connected social identity provider."
|
||||||
|
},
|
||||||
|
"expiresAt": {
|
||||||
|
"description": "The expiration date and time of the verification record."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "The connector specified by connectorId is not found."
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"description": "The connector specified by connectorId is not a valid social connector."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/verifications/social/verify": {
|
||||||
|
"post": {
|
||||||
|
"operationId": "VerifyVerificationBySocial",
|
||||||
|
"summary": "Verify a social verification record",
|
||||||
|
"description": "Verify a social verification record by callback connector data, and save the user information to the record.",
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"properties": {
|
||||||
|
"connectorData": {
|
||||||
|
"description": "A json object constructed from the url query params returned by the social platform. Typically it contains `code`, `state` and `redirectUri` fields."
|
||||||
|
},
|
||||||
|
"verificationId": {
|
||||||
|
"description": "The verification ID of the SocialVerification record."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "The social verification record has been successfully verified and the user information has been saved."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,8 @@ import {
|
||||||
AdditionalIdentifier,
|
AdditionalIdentifier,
|
||||||
SentinelActivityAction,
|
SentinelActivityAction,
|
||||||
SignInIdentifier,
|
SignInIdentifier,
|
||||||
|
socialAuthorizationUrlPayloadGuard,
|
||||||
|
socialVerificationCallbackPayloadGuard,
|
||||||
verificationCodeIdentifierGuard,
|
verificationCodeIdentifierGuard,
|
||||||
VerificationType,
|
VerificationType,
|
||||||
} from '@logto/schemas';
|
} from '@logto/schemas';
|
||||||
|
@ -19,11 +21,14 @@ import {
|
||||||
import { withSentinel } from '../experience/classes/libraries/sentinel-guard.js';
|
import { withSentinel } from '../experience/classes/libraries/sentinel-guard.js';
|
||||||
import { createNewCodeVerificationRecord } from '../experience/classes/verifications/code-verification.js';
|
import { createNewCodeVerificationRecord } from '../experience/classes/verifications/code-verification.js';
|
||||||
import { PasswordVerification } from '../experience/classes/verifications/password-verification.js';
|
import { PasswordVerification } from '../experience/classes/verifications/password-verification.js';
|
||||||
|
import { SocialVerification } from '../experience/classes/verifications/social-verification.js';
|
||||||
import type { UserRouter, RouterInitArgs } from '../types.js';
|
import type { UserRouter, RouterInitArgs } from '../types.js';
|
||||||
|
|
||||||
export default function verificationRoutes<T extends UserRouter>(
|
export default function verificationRoutes<T extends UserRouter>(
|
||||||
...[router, { queries, libraries, sentinel }]: RouterInitArgs<T>
|
...[router, tenantContext]: RouterInitArgs<T>
|
||||||
) {
|
) {
|
||||||
|
const { queries, libraries, sentinel } = tenantContext;
|
||||||
|
|
||||||
if (!EnvSet.values.isDevFeaturesEnabled) {
|
if (!EnvSet.values.isDevFeaturesEnabled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -157,4 +162,79 @@ export default function verificationRoutes<T extends UserRouter>(
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/verifications/social',
|
||||||
|
koaGuard({
|
||||||
|
body: socialAuthorizationUrlPayloadGuard.extend({
|
||||||
|
connectorId: z.string(),
|
||||||
|
}),
|
||||||
|
response: z.object({
|
||||||
|
verificationRecordId: z.string(),
|
||||||
|
authorizationUri: z.string(),
|
||||||
|
expiresAt: z.string(),
|
||||||
|
}),
|
||||||
|
status: [201, 400, 404, 422],
|
||||||
|
}),
|
||||||
|
async (ctx, next) => {
|
||||||
|
const { connectorId, ...rest } = ctx.guard.body;
|
||||||
|
|
||||||
|
const socialVerification = SocialVerification.create(libraries, queries, connectorId);
|
||||||
|
|
||||||
|
const authorizationUri = await socialVerification.createAuthorizationUrl(
|
||||||
|
ctx,
|
||||||
|
tenantContext,
|
||||||
|
rest,
|
||||||
|
'verificationRecord'
|
||||||
|
);
|
||||||
|
|
||||||
|
const { expiresAt } = await insertVerificationRecord(socialVerification, queries);
|
||||||
|
|
||||||
|
ctx.body = {
|
||||||
|
verificationRecordId: socialVerification.id,
|
||||||
|
authorizationUri,
|
||||||
|
expiresAt: new Date(expiresAt).toISOString(),
|
||||||
|
};
|
||||||
|
ctx.status = 201;
|
||||||
|
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/verifications/social/verify',
|
||||||
|
koaGuard({
|
||||||
|
body: socialVerificationCallbackPayloadGuard
|
||||||
|
.pick({
|
||||||
|
connectorData: true,
|
||||||
|
})
|
||||||
|
.extend({
|
||||||
|
verificationRecordId: z.string(),
|
||||||
|
}),
|
||||||
|
response: z.object({
|
||||||
|
verificationRecordId: z.string(),
|
||||||
|
}),
|
||||||
|
status: [200, 400, 404, 422],
|
||||||
|
}),
|
||||||
|
async (ctx, next) => {
|
||||||
|
const { connectorData, verificationRecordId } = ctx.guard.body;
|
||||||
|
|
||||||
|
const socialVerification = await buildVerificationRecordByIdAndType({
|
||||||
|
type: VerificationType.Social,
|
||||||
|
id: verificationRecordId,
|
||||||
|
queries,
|
||||||
|
libraries,
|
||||||
|
});
|
||||||
|
|
||||||
|
await socialVerification.verify(ctx, tenantContext, connectorData, 'verificationRecord');
|
||||||
|
|
||||||
|
await updateVerificationRecord(socialVerification, queries);
|
||||||
|
|
||||||
|
ctx.body = {
|
||||||
|
verificationRecordId,
|
||||||
|
};
|
||||||
|
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -27,6 +27,15 @@ export const updatePrimaryPhone = async (
|
||||||
json: { phone, verificationRecordId, newIdentifierVerificationRecordId },
|
json: { phone, verificationRecordId, newIdentifierVerificationRecordId },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const updateIdentities = async (
|
||||||
|
api: KyInstance,
|
||||||
|
verificationRecordId: string,
|
||||||
|
newIdentifierVerificationRecordId: string
|
||||||
|
) =>
|
||||||
|
api.post('api/profile/identities', {
|
||||||
|
json: { verificationRecordId, newIdentifierVerificationRecordId },
|
||||||
|
});
|
||||||
|
|
||||||
export const updateUser = async (api: KyInstance, body: Record<string, unknown>) =>
|
export const updateUser = async (api: KyInstance, body: Record<string, unknown>) =>
|
||||||
api.patch('api/profile', { json: body }).json<Partial<UserProfileResponse>>();
|
api.patch('api/profile', { json: body }).json<Partial<UserProfileResponse>>();
|
||||||
|
|
||||||
|
|
|
@ -70,3 +70,31 @@ export const createAndVerifyVerificationCode = async (
|
||||||
|
|
||||||
return verificationRecordId;
|
return verificationRecordId;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const createSocialVerificationRecord = async (
|
||||||
|
api: KyInstance,
|
||||||
|
connectorId: string,
|
||||||
|
state: string,
|
||||||
|
redirectUri: string
|
||||||
|
) => {
|
||||||
|
const { verificationRecordId, authorizationUri, expiresAt } = await api
|
||||||
|
.post('api/verifications/social', {
|
||||||
|
json: { connectorId, state, redirectUri },
|
||||||
|
})
|
||||||
|
.json<{ verificationRecordId: string; authorizationUri: string; expiresAt: string }>();
|
||||||
|
|
||||||
|
expect(expiresAt).toBeTruthy();
|
||||||
|
expect(authorizationUri).toBeTruthy();
|
||||||
|
|
||||||
|
return { verificationRecordId, authorizationUri };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const verifySocialAuthorization = async (
|
||||||
|
api: KyInstance,
|
||||||
|
verificationRecordId: string,
|
||||||
|
connectorData: Record<string, unknown>
|
||||||
|
) => {
|
||||||
|
await api.post('api/verifications/social/verify', {
|
||||||
|
json: { verificationRecordId, connectorData },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
169
packages/integration-tests/src/tests/api/profile/social.test.ts
Normal file
169
packages/integration-tests/src/tests/api/profile/social.test.ts
Normal file
|
@ -0,0 +1,169 @@
|
||||||
|
import { UserScope } from '@logto/core-kit';
|
||||||
|
import { ConnectorType } from '@logto/schemas';
|
||||||
|
|
||||||
|
import {
|
||||||
|
mockEmailConnectorId,
|
||||||
|
mockSocialConnectorId,
|
||||||
|
mockSocialConnectorTarget,
|
||||||
|
} from '#src/__mocks__/connectors-mock.js';
|
||||||
|
import { getUserInfo, updateIdentities } from '#src/api/profile.js';
|
||||||
|
import {
|
||||||
|
createSocialVerificationRecord,
|
||||||
|
createVerificationRecordByPassword,
|
||||||
|
verifySocialAuthorization,
|
||||||
|
} from '#src/api/verification-record.js';
|
||||||
|
import {
|
||||||
|
clearConnectorsByTypes,
|
||||||
|
setEmailConnector,
|
||||||
|
setSocialConnector,
|
||||||
|
} from '#src/helpers/connector.js';
|
||||||
|
import { expectRejects } from '#src/helpers/index.js';
|
||||||
|
import {
|
||||||
|
createDefaultTenantUserWithPassword,
|
||||||
|
deleteDefaultTenantUser,
|
||||||
|
signInAndGetUserApi,
|
||||||
|
} from '#src/helpers/profile.js';
|
||||||
|
import { enableAllPasswordSignInMethods } from '#src/helpers/sign-in-experience.js';
|
||||||
|
import { devFeatureTest } from '#src/utils.js';
|
||||||
|
|
||||||
|
const { describe, it } = devFeatureTest;
|
||||||
|
|
||||||
|
describe('profile (social)', () => {
|
||||||
|
const state = 'fake_state';
|
||||||
|
const redirectUri = 'http://localhost:3000/redirect';
|
||||||
|
const authorizationCode = 'fake_code';
|
||||||
|
const connectorIdMap = new Map<string, string>();
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await enableAllPasswordSignInMethods();
|
||||||
|
|
||||||
|
await clearConnectorsByTypes([ConnectorType.Social]);
|
||||||
|
const { id: socialConnectorId } = await setSocialConnector();
|
||||||
|
const { id: emailConnectorId } = await setEmailConnector();
|
||||||
|
connectorIdMap.set(mockSocialConnectorId, socialConnectorId);
|
||||||
|
connectorIdMap.set(mockEmailConnectorId, emailConnectorId);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await clearConnectorsByTypes([ConnectorType.Social, ConnectorType.Email]);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /profile/identities', () => {
|
||||||
|
it('should fail if scope is missing', async () => {
|
||||||
|
const { user, username, password } = await createDefaultTenantUserWithPassword();
|
||||||
|
const api = await signInAndGetUserApi(username, password);
|
||||||
|
|
||||||
|
await expectRejects(
|
||||||
|
updateIdentities(api, 'invalid-verification-record-id', 'new-verification-record-id'),
|
||||||
|
{
|
||||||
|
code: 'auth.unauthorized',
|
||||||
|
status: 400,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await deleteDefaultTenantUser(user.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail if verification record is invalid', async () => {
|
||||||
|
const { user, username, password } = await createDefaultTenantUserWithPassword();
|
||||||
|
const api = await signInAndGetUserApi(username, password, {
|
||||||
|
scopes: [UserScope.Profile, UserScope.Identities],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expectRejects(
|
||||||
|
updateIdentities(api, 'invalid-verification-record-id', 'new-verification-record-id'),
|
||||||
|
{
|
||||||
|
code: 'verification_record.permission_denied',
|
||||||
|
status: 401,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await deleteDefaultTenantUser(user.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail if new identifier verification record is invalid', async () => {
|
||||||
|
const { user, username, password } = await createDefaultTenantUserWithPassword();
|
||||||
|
const api = await signInAndGetUserApi(username, password, {
|
||||||
|
scopes: [UserScope.Profile, UserScope.Identities],
|
||||||
|
});
|
||||||
|
const verificationRecordId = await createVerificationRecordByPassword(api, password);
|
||||||
|
|
||||||
|
await expectRejects(
|
||||||
|
updateIdentities(api, verificationRecordId, 'new-verification-record-id'),
|
||||||
|
{
|
||||||
|
code: 'verification_record.not_found',
|
||||||
|
status: 400,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await deleteDefaultTenantUser(user.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('create social verification record', () => {
|
||||||
|
it('should throw if the connector is not found', async () => {
|
||||||
|
const { user, username, password } = await createDefaultTenantUserWithPassword();
|
||||||
|
const api = await signInAndGetUserApi(username, password, {
|
||||||
|
scopes: [UserScope.Profile, UserScope.Identities],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expectRejects(
|
||||||
|
createSocialVerificationRecord(api, 'invalid-connector-id', state, redirectUri),
|
||||||
|
{
|
||||||
|
code: 'session.invalid_connector_id',
|
||||||
|
status: 422,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await deleteDefaultTenantUser(user.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw if the connector is not a social connector', async () => {
|
||||||
|
const { user, username, password } = await createDefaultTenantUserWithPassword();
|
||||||
|
const api = await signInAndGetUserApi(username, password, {
|
||||||
|
scopes: [UserScope.Profile, UserScope.Identities],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expectRejects(
|
||||||
|
createSocialVerificationRecord(
|
||||||
|
api,
|
||||||
|
connectorIdMap.get(mockEmailConnectorId)!,
|
||||||
|
state,
|
||||||
|
redirectUri
|
||||||
|
),
|
||||||
|
{
|
||||||
|
code: 'connector.unexpected_type',
|
||||||
|
status: 400,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await deleteDefaultTenantUser(user.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be able to verify social authorization and update user identities', async () => {
|
||||||
|
const { user, username, password } = await createDefaultTenantUserWithPassword();
|
||||||
|
const api = await signInAndGetUserApi(username, password, {
|
||||||
|
scopes: [UserScope.Profile, UserScope.Identities],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { verificationRecordId: newVerificationRecordId } =
|
||||||
|
await createSocialVerificationRecord(
|
||||||
|
api,
|
||||||
|
connectorIdMap.get(mockSocialConnectorId)!,
|
||||||
|
state,
|
||||||
|
redirectUri
|
||||||
|
);
|
||||||
|
|
||||||
|
await verifySocialAuthorization(api, newVerificationRecordId, {
|
||||||
|
code: authorizationCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
const verificationRecordId = await createVerificationRecordByPassword(api, password);
|
||||||
|
await updateIdentities(api, verificationRecordId, newVerificationRecordId);
|
||||||
|
const userInfo = await getUserInfo(api);
|
||||||
|
expect(userInfo.identities).toHaveProperty(mockSocialConnectorTarget);
|
||||||
|
|
||||||
|
await deleteDefaultTenantUser(user.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -1,6 +1,7 @@
|
||||||
const verification_record = {
|
const verification_record = {
|
||||||
not_found: 'Verification record not found.',
|
not_found: 'Verification record not found.',
|
||||||
permission_denied: 'Permission denied, please re-authenticate.',
|
permission_denied: 'Permission denied, please re-authenticate.',
|
||||||
|
not_supported_for_google_one_tap: 'This API does not support Google One Tap.',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Object.freeze(verification_record);
|
export default Object.freeze(verification_record);
|
||||||
|
|
Loading…
Reference in a new issue