Skip to content

Commit

Permalink
feat(core): add social identity (#6703)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
  • Loading branch information
wangsijie and simeng-li authored Oct 26, 2024
1 parent 5003136 commit 9742f05
Show file tree
Hide file tree
Showing 10 changed files with 608 additions and 22 deletions.
8 changes: 7 additions & 1 deletion packages/core/src/libraries/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export const createUserLibrary = (queries: Queries) => {
hasUserWithEmail,
hasUserWithId,
hasUserWithPhone,
hasUserWithIdentity,
findUsersByIds,
updateUserById,
findUserById,
Expand Down Expand Up @@ -91,10 +92,11 @@ export const createUserLibrary = (queries: Queries) => {
username?: Nullable<string>;
primaryEmail?: Nullable<string>;
primaryPhone?: Nullable<string>;
identity?: Nullable<{ target: string; id: string }>;
},
excludeUserId?: string
) => {
const { primaryEmail, primaryPhone, username } = identifiers;
const { primaryEmail, primaryPhone, username, identity } = identifiers;

if (primaryEmail && (await hasUserWithEmail(primaryEmail, excludeUserId))) {
throw new RequestError({ code: 'user.email_already_in_use', status: 422 });
Expand All @@ -107,6 +109,10 @@ export const createUserLibrary = (queries: Queries) => {
if (username && (await hasUser(username, excludeUserId))) {
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) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
VerificationType,
type JsonObject,
Expand Down Expand Up @@ -34,15 +43,22 @@ export type SocialVerificationRecordData = {
* The social identity returned by the connector.
*/
socialUserInfo?: SocialUserInfo;
/**
* The connector session result
*/
connectorSession?: ConnectorSession;
};

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

type SocialAuthorizationSessionStorageType = 'interactionSession' | 'verificationRecord';

export class SocialVerification implements IdentifierVerificationRecord<VerificationType.Social> {
/**
* Factory method to create a new SocialVerification instance
Expand All @@ -59,19 +75,21 @@ export class SocialVerification implements IdentifierVerificationRecord<Verifica
public readonly type = VerificationType.Social;
public readonly connectorId: string;
public socialUserInfo?: SocialUserInfo;

public connectorSession?: ConnectorSession;
private connectorDataCache?: LogtoConnector;

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

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

/**
Expand All @@ -82,26 +100,40 @@ export class SocialVerification implements IdentifierVerificationRecord<Verifica
}

/**
* Create the authorization URL for the social connector.
* Store the connector session result in the provider's interaction storage.
* Create the authorization URL for the social connector and generate a connector authorization session.
*
* @param {SocialAuthorizationSessionStorageType} connectorSessionType - Whether to store the connector session result in the current verification record directly. Set to `true` for the profile API.
*
* @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 the experience API:
* This method directly calls the {@link createSocialAuthorizationUrl} method in the interaction/utils/social-verification.ts file.
* 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.
* 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.
*/
async createAuthorizationUrl(
ctx: WithLogContext,
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, {
connectorId: this.connectorId,
state,
Expand All @@ -112,19 +144,36 @@ export class SocialVerification implements IdentifierVerificationRecord<Verifica
/**
* 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
* 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.
* 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) {
const socialUserInfo = await verifySocialIdentity(
{ connectorId: this.connectorId, connectorData },
ctx,
tenantContext
);
async verify(
ctx: WithLogContext,
tenantContext: TenantContext,
connectorData: JsonObject,
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;
}
Expand Down Expand Up @@ -235,13 +284,14 @@ export class SocialVerification implements IdentifierVerificationRecord<Verifica
}

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

return {
id,
connectorId,
type,
socialUserInfo,
connectorSession,
};
}

Expand Down Expand Up @@ -278,11 +328,85 @@ export class SocialVerification implements IdentifierVerificationRecord<Verifica
return socials.findSocialRelatedUser(this.socialUserInfo);
}

private async getConnectorData() {
private async getConnectorData(): Promise<LogtoConnector<SocialConnector>> {
const { getConnector } = this.libraries.socials;

this.connectorDataCache ||= await getConnector(this.connectorId);

assertThat(this.connectorDataCache.type === ConnectorType.Social, 'connector.unexpected_type');

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;
}
}
28 changes: 28 additions & 0 deletions packages/core/src/routes/profile/index.openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
}
}
}
}
}
59 changes: 59 additions & 0 deletions packages/core/src/routes/profile/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,4 +236,63 @@ export default function profileRoutes<T extends UserRouter>(
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();
}
);
}
Loading

0 comments on commit 9742f05

Please sign in to comment.