0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-10 22:22:45 -05:00

feat(cloud): new APIs for social linking/unlinking in cloud AC (#3184)

This commit is contained in:
Charles Zhao 2023-02-24 15:44:15 +08:00 committed by GitHub
parent 77d90e8e4e
commit 586cc96113
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 188 additions and 0 deletions

View file

@ -15,6 +15,8 @@ import koaGuard from '#src/middleware/koa-guard.js';
import type TenantContext from '#src/tenants/TenantContext.js';
import assertThat from '#src/utils/assert-that.js';
import socialRoutes from './social.js';
export default function initMeApis(tenant: TenantContext): Koa {
if (tenant.id !== adminTenantId) {
throw new Error('`/me` routes should only be initialized in the admin tenant.');
@ -66,6 +68,8 @@ export default function initMeApis(tenant: TenantContext): Koa {
}
);
socialRoutes(meRouter, tenant);
const meApp = new Koa();
meApp.use(koaCors(EnvSet.values.cloudUrlSet));
meApp.use(meRouter.routes()).use(meRouter.allowedMethods());

View file

@ -0,0 +1,171 @@
import { ConnectorType } from '@logto/schemas';
import { has } from '@silverhand/essentials';
import { object, record, string, unknown } from 'zod';
import RequestError from '#src/errors/RequestError/index.js';
import koaGuard from '#src/middleware/koa-guard.js';
import assertThat from '#src/utils/assert-that.js';
import { notImplemented } from '#src/utils/connectors/consts.js';
import type { RouterInitArgs } from '../routes/types.js';
import type { AuthedMeRouter } from './types.js';
/**
* This social API route is meant for linking social accounts in Logto Cloud AC.
* Thus it does NOT support connectors rely on the session (jti based) storage. E.g. Apple connector and all standard connectors.
* This is because Logto Cloud AC only supports Google and GitHub social sign-in, both of which do not rely on session storage.
*/
export default function socialRoutes<T extends AuthedMeRouter>(
...[router, tenant]: RouterInitArgs<T>
) {
const {
libraries: {
connectors: { getLogtoConnectorById },
},
queries: {
users: { findUserById, updateUserById, deleteUserIdentity, hasUserWithIdentity },
},
} = tenant;
router.post(
'/social-authorization-uri',
koaGuard({
body: object({ connectorId: string(), state: string(), redirectUri: string() }),
}),
async (ctx, next) => {
const { connectorId, state, redirectUri } = ctx.guard.body;
assertThat(state && redirectUri, 'session.insufficient_info');
const connector = await getLogtoConnectorById(connectorId);
assertThat(connector.type === ConnectorType.Social, 'connector.unexpected_type');
const {
headers: { 'user-agent': userAgent },
} = ctx.request;
const redirectTo = await connector.getAuthorizationUri(
{
state,
redirectUri,
connectorId,
connectorFactoryId: connector.metadata.id,
/**
* Passing empty jti only works for connectors not relying on session storage.
* E.g. Google and GitHub
*/
jti: '',
headers: { userAgent },
},
/**
* Same as above, passing `notImplemented` only works for connectors not relying on session storage.
* E.g. Google and GitHub
*/
notImplemented
);
ctx.body = { redirectTo };
return next();
}
);
router.post(
'/link-social-identity',
koaGuard({
body: object({
connectorId: string(),
connectorData: record(string(), unknown()),
}),
}),
async (ctx, next) => {
const { id: userId } = ctx.auth;
const { connectorId, connectorData } = ctx.guard.body;
const [connector, user] = await Promise.all([
getLogtoConnectorById(connectorId),
findUserById(userId),
]);
assertThat(
connector.type === ConnectorType.Social,
new RequestError({
code: 'session.invalid_connector_id',
status: 422,
connectorId,
})
);
const { target } = connector.metadata;
assertThat(
!has(user.identities, target),
new RequestError({
code: 'user.social_account_exists_in_profile',
status: 422,
})
);
/**
* Same as above, passing `notImplemented` only works for connectors not relying on session storage.
* E.g. Google and GitHub
*/
const socialUserInfo = await connector.getUserInfo(connectorData, notImplemented, {
get: notImplemented,
set: notImplemented,
});
assertThat(
!(await hasUserWithIdentity(target, socialUserInfo.id)),
new RequestError({
code: 'user.identity_already_in_use',
status: 422,
})
);
await updateUserById(userId, {
identities: {
...user.identities,
[target]: {
userId: socialUserInfo.id,
details: socialUserInfo,
},
},
});
ctx.status = 204;
return next();
}
);
router.delete(
'/social-identity/:connectorId',
koaGuard({
params: object({
connectorId: string(),
}),
}),
async (ctx, next) => {
const { id: userId } = ctx.auth;
const { connectorId } = ctx.guard.params;
const [connector, user] = await Promise.all([
getLogtoConnectorById(connectorId),
findUserById(userId),
]);
const { target } = connector.metadata;
assertThat(
has(user.identities, target),
new RequestError({ code: 'user.identity_not_exist', status: 404 })
);
await deleteUserIdentity(userId, target);
ctx.status = 204;
return next();
}
);
}

View file

@ -0,0 +1,5 @@
import type Router from 'koa-router';
import type { WithAuthContext } from '#src/middleware/koa-auth/index.js';
export type AuthedMeRouter = Router<unknown, WithAuthContext>;

View file

@ -45,6 +45,7 @@ const errors = {
phone_not_exist: 'Die Telefonnummer wurde noch nicht registriert.',
identity_not_exist: 'Die Identität wurde noch nicht registriert.',
identity_already_in_use: 'Die Identität wurde registriert.',
social_account_exists_in_profile: 'You have already associated this social account.', // UNTRANSLATED
cannot_delete_self: 'Du kannst dich nicht selbst löschen.',
sign_up_method_not_enabled: 'This sign-up method is not enabled.', // UNTRANSLATED
sign_in_method_not_enabled: 'This sign-in method is not enabled.', // UNTRANSLATED

View file

@ -45,6 +45,7 @@ const errors = {
phone_not_exist: 'The phone number has not been registered yet.',
identity_not_exist: 'The social account has not been registered yet.',
identity_already_in_use: 'The social account has been associated with an existing account.',
social_account_exists_in_profile: 'You have already associated this social account.',
cannot_delete_self: 'You cannot delete yourself.',
sign_up_method_not_enabled: 'This sign-up method is not enabled.',
sign_in_method_not_enabled: 'This sign-in method is not enabled.',

View file

@ -46,6 +46,7 @@ const errors = {
phone_not_exist: "Le numéro de téléphone n'a pas encore été enregistré.",
identity_not_exist: "Le compte social n'a pas encore été enregistré.",
identity_already_in_use: 'Le compte social a été enregistré.',
social_account_exists_in_profile: 'You have already associated this social account.', // UNTRANSLATED
cannot_delete_self: 'You cannot delete yourself.', // UNTRANSLATED
sign_up_method_not_enabled: 'This sign-up method is not enabled.', // UNTRANSLATED
sign_in_method_not_enabled: 'This sign-in method is not enabled.', // UNTRANSLATED

View file

@ -44,6 +44,7 @@ const errors = {
phone_not_exist: '휴대전화번호가 아직 등록되지 않았어요.',
identity_not_exist: '소셜 계정이 아직 등록되지 않았어요.',
identity_already_in_use: '소셜 계정이 이미 등록되어 있어요.',
social_account_exists_in_profile: 'You have already associated this social account.', // UNTRANSLATED
cannot_delete_self: '자기 자신을 삭제할 수 없어요.',
sign_up_method_not_enabled: '이 회원가입 방법은 활성화되어있지 않아요.',
sign_in_method_not_enabled: '이 로그인 방법은 활성화되어있지 않아요.',

View file

@ -45,6 +45,7 @@ const errors = {
phone_not_exist: 'O número de telefone ainda não foi registrado.',
identity_not_exist: 'A conta social ainda não foi registrada.',
identity_already_in_use: 'A conta social foi associada a uma conta existente.',
social_account_exists_in_profile: 'You have already associated this social account.', // UNTRANSLATED
cannot_delete_self: 'Você não pode excluir a si mesmo.',
sign_up_method_not_enabled: 'Este método de inscrição não está ativado',
sign_in_method_not_enabled: 'Este método de login não está habilitado.',

View file

@ -44,6 +44,7 @@ const errors = {
phone_not_exist: 'O numero do telefone ainda não foi registada.',
identity_not_exist: 'A conta social ainda não foi registada.',
identity_already_in_use: 'A conta social foi registada.',
social_account_exists_in_profile: 'You have already associated this social account.', // UNTRANSLATED
cannot_delete_self: 'Não se pode remover a si mesmo.',
sign_up_method_not_enabled: 'This sign-up method is not enabled.', // UNTRANSLATED
sign_in_method_not_enabled: 'This sign-in method is not enabled.', // UNTRANSLATED

View file

@ -45,6 +45,7 @@ const errors = {
phone_not_exist: 'Telefon numarası henüz kaydedilmedi',
identity_not_exist: 'Sosyal platform hesabı henüz kaydedilmedi.',
identity_already_in_use: 'Sosyal platform hesabı kaydedildi.',
social_account_exists_in_profile: 'You have already associated this social account.', // UNTRANSLATED
cannot_delete_self: 'You cannot delete yourself.', // UNTRANSLATED
sign_up_method_not_enabled: 'This sign-up method is not enabled.', // UNTRANSLATED
sign_in_method_not_enabled: 'This sign-in method is not enabled.', // UNTRANSLATED

View file

@ -43,6 +43,7 @@ const errors = {
phone_not_exist: '手机号码尚未注册。',
identity_not_exist: '该社交帐号尚未注册。',
identity_already_in_use: '该社交帐号已被注册。',
social_account_exists_in_profile: '你已绑定当前社交账号,无需重复操作。',
cannot_delete_self: '无法删除自己的账户。',
sign_up_method_not_enabled: '注册方式尚未启用。',
sign_in_method_not_enabled: '登录方式尚未启用。',