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:
parent
77d90e8e4e
commit
586cc96113
11 changed files with 188 additions and 0 deletions
|
@ -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());
|
||||
|
|
171
packages/core/src/routes-me/social.ts
Normal file
171
packages/core/src/routes-me/social.ts
Normal 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();
|
||||
}
|
||||
);
|
||||
}
|
5
packages/core/src/routes-me/types.ts
Normal file
5
packages/core/src/routes-me/types.ts
Normal 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>;
|
|
@ -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
|
||||
|
|
|
@ -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.',
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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: '이 로그인 방법은 활성화되어있지 않아요.',
|
||||
|
|
|
@ -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.',
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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: '登录方式尚未启用。',
|
||||
|
|
Loading…
Add table
Reference in a new issue