Skip to content

Commit

Permalink
feat(core): add social identity
Browse files Browse the repository at this point in the history
  • Loading branch information
wangsijie committed Oct 21, 2024
1 parent 61aa13f commit 6ed9c2e
Show file tree
Hide file tree
Showing 10 changed files with 526 additions and 24 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 });
}

Check warning on line 115 in packages/core/src/libraries/user.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/libraries/user.ts#L114-L115

Added lines #L114 - L115 were not covered by tests
};

const findUsersByRoleName = async (roleName: string) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { socialUserInfoGuard, type SocialUserInfo, type ToZodObject } from '@logto/connector-kit';
import {
type ConnectorSession,
connectorSessionGuard,
socialUserInfoGuard,
type SocialUserInfo,
type ToZodObject,
} from '@logto/connector-kit';
import {
VerificationType,
type JsonObject,
Expand Down Expand Up @@ -34,13 +40,18 @@ 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>;

export class SocialVerification implements IdentifierVerificationRecord<VerificationType.Social> {
Expand All @@ -59,19 +70,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 Down Expand Up @@ -102,11 +115,21 @@ export class SocialVerification implements IdentifierVerificationRecord<Verifica
tenantContext: TenantContext,
{ state, redirectUri }: SocialAuthorizationUrlPayload
) {
return createSocialAuthorizationUrl(ctx, tenantContext, {
connectorId: this.connectorId,
state,
redirectUri,
});
return createSocialAuthorizationUrl(
ctx,
tenantContext,
{
connectorId: this.connectorId,
state,
redirectUri,
},
{
setSession: async (connectorSession) => {
this.connectorSession = connectorSession;
},
jti: this.id,
}
);

Check warning on line 132 in packages/core/src/routes/experience/classes/verifications/social-verification.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/classes/verifications/social-verification.ts#L118-L132

Added lines #L118 - L132 were not covered by tests
}

/**
Expand All @@ -119,11 +142,17 @@ export class SocialVerification implements IdentifierVerificationRecord<Verifica
*
* TODO: check the log event
*/
async verify(ctx: WithLogContext, tenantContext: TenantContext, connectorData: JsonObject) {
async verify(
ctx: WithLogContext,
tenantContext: TenantContext,
connectorData: JsonObject,
skipInteractionLogging = false
) {

Check warning on line 150 in packages/core/src/routes/experience/classes/verifications/social-verification.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/classes/verifications/social-verification.ts#L146-L150

Added lines #L146 - L150 were not covered by tests
const socialUserInfo = await verifySocialIdentity(
{ connectorId: this.connectorId, connectorData },
ctx,
tenantContext
tenantContext,
{ getSession: async () => this.connectorSession ?? {}, skipInteractionLogging }

Check warning on line 155 in packages/core/src/routes/experience/classes/verifications/social-verification.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/classes/verifications/social-verification.ts#L154-L155

Added lines #L154 - L155 were not covered by tests
);

this.socialUserInfo = socialUserInfo;
Expand Down Expand Up @@ -235,13 +264,14 @@ export class SocialVerification implements IdentifierVerificationRecord<Verifica
}

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

Check warning on line 267 in packages/core/src/routes/experience/classes/verifications/social-verification.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/classes/verifications/social-verification.ts#L267

Added line #L267 was not covered by tests

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

Check warning on line 274 in packages/core/src/routes/experience/classes/verifications/social-verification.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/classes/verifications/social-verification.ts#L274

Added line #L274 was not covered by tests
};
}

Expand Down
37 changes: 26 additions & 11 deletions packages/core/src/routes/interaction/utils/social-verification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ import type { SocialAuthorizationUrlPayload } from '../types/index.js';
export const createSocialAuthorizationUrl = async (
ctx: WithLogContext,
{ provider, connectors }: TenantContext,
payload: SocialAuthorizationUrlPayload
payload: SocialAuthorizationUrlPayload,
{
setSession,
jti,
}: { setSession?: (connectorSession: ConnectorSession) => Promise<void>; jti?: string } = {}
) => {
const { getLogtoConnectorById } = connectors;

Expand All @@ -30,7 +34,7 @@ export const createSocialAuthorizationUrl = async (
headers: { 'user-agent': userAgent },
} = ctx.request;

const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
const { jti: finalJti } = jti ? { jti } : await provider.interactionDetails(ctx.req, ctx.res);

return connector.getAuthorizationUri(
{
Expand All @@ -43,25 +47,34 @@ export const createSocialAuthorizationUrl = async (
*/
connectorId,
connectorFactoryId: connector.metadata.id,
jti,
jti: finalJti,
headers: { userAgent },
},
async (connectorStorage: ConnectorSession) =>
assignConnectorSessionResult(ctx, provider, connectorStorage)
// TODO(LOG-10266): remove this and migrate all connector session result to verification record

Check warning on line 53 in packages/core/src/routes/interaction/utils/social-verification.ts

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/core/src/routes/interaction/utils/social-verification.ts#L53

[no-warning-comments] Unexpected 'todo' comment: 'TODO(LOG-10266): remove this and migrate...'.
setSession ??
(async (connectorStorage: ConnectorSession) =>
assignConnectorSessionResult(ctx, provider, connectorStorage))
);
};

export const verifySocialIdentity = async (
{ connectorId, connectorData }: SocialConnectorPayload,
ctx: WithLogContext,
{ provider, libraries }: TenantContext
{ provider, libraries }: TenantContext,
{
getSession,
skipInteractionLogging,
}: { getSession?: () => Promise<ConnectorSession>; skipInteractionLogging?: boolean } = {}
): Promise<SocialUserInfo> => {
const {
socials: { getUserInfo, getConnector },
} = libraries;

const log = ctx.createLog('Interaction.SignIn.Identifier.Social.Submit');
log.append({ connectorId, connectorData });
// TODO(LOG-10268): move logging to experience api layer

Check warning on line 73 in packages/core/src/routes/interaction/utils/social-verification.ts

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/core/src/routes/interaction/utils/social-verification.ts#L73

[no-warning-comments] Unexpected 'todo' comment: 'TODO(LOG-10268): move logging to...'.
const log = skipInteractionLogging
? undefined
: ctx.createLog('Interaction.SignIn.Identifier.Social.Submit');
log?.append({ connectorId, connectorData });

const connector = await getConnector(connectorId);

Expand All @@ -76,11 +89,13 @@ export const verifySocialIdentity = async (
assertThat(value === csrfToken, 'session.csrf_token_mismatch');
}

const userInfo = await getUserInfo(connectorId, connectorData, async () =>
getConnectorSessionResult(ctx, provider)
const userInfo = await getUserInfo(
connectorId,
connectorData,
getSession ?? (async () => getConnectorSessionResult(ctx, provider))
);

log.append(userInfo);
log?.append(userInfo);

return userInfo;
};
Expand Down
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();
}

Check warning on line 296 in packages/core/src/routes/profile/index.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/profile/index.ts#L250-L296

Added lines #L250 - L296 were not covered by tests
);
}
Loading

0 comments on commit 6ed9c2e

Please sign in to comment.