diff --git a/apps/console/app/utilities/session.server.tsx b/apps/console/app/utilities/session.server.tsx index e8bccb89d7..caf4563564 100644 --- a/apps/console/app/utilities/session.server.tsx +++ b/apps/console/app/utilities/session.server.tsx @@ -12,8 +12,15 @@ import { Session, } from '@remix-run/cloudflare' -import { decryptSession } from '@proofzero/utils/session' +import createCoreClient from '@proofzero/platform-clients/core' +import { + generateTraceContextHeaders, + generateTraceSpan, +} from '@proofzero/platform-middleware/trace' +import { IdentityURNSpace } from '@proofzero/urns/identity' +import { decryptSession } from '@proofzero/utils/session' +import { getAuthzHeaderConditionallyFromToken } from '@proofzero/utils' import { checkToken, ExpiredTokenError, @@ -63,7 +70,19 @@ export async function requireJWT(request: Request, env: Env) { const jwt = await getUserSession(request, env) try { - checkToken(jwt) + const { sub: subject } = checkToken(jwt) + if (!subject) throw InvalidTokenError + + const coreClient = createCoreClient(env.Core, { + ...getAuthzHeaderConditionallyFromToken(jwt), + ...generateTraceContextHeaders(generateTraceSpan()), + }) + + if ( + !IdentityURNSpace.is(subject) || + !(await coreClient.identity.isValid.query()) + ) + throw InvalidTokenError return jwt } catch (error) { switch (error) { diff --git a/apps/passport/app/routes/authorize.tsx b/apps/passport/app/routes/authorize.tsx index 22d0b3a717..7a2d709a2a 100644 --- a/apps/passport/app/routes/authorize.tsx +++ b/apps/passport/app/routes/authorize.tsx @@ -81,6 +81,10 @@ export const loader: LoaderFunction = getRollupReqFunctionErrorWrapper( const connectResult = new URL(request.url).searchParams.get('rollup_result') ?? undefined + if (connectResult === 'ACCOUNT_LINKED_ERROR') { + throw redirect('/merge-identity') + } + //Request parameter pre-checks if (!clientId) throw new BadRequestError({ message: 'client_id is required' }) @@ -238,7 +242,7 @@ export const loader: LoaderFunction = getRollupReqFunctionErrorWrapper( const responseType = ResponseType.Code const preauthorizeRes = await coreClient.authorization.preauthorize.mutate({ - identity: identityURN, + identityURN, responseType, clientId, redirectUri, diff --git a/apps/passport/app/routes/merge-identity/cancel.tsx b/apps/passport/app/routes/merge-identity/cancel.tsx new file mode 100644 index 0000000000..8c680faeca --- /dev/null +++ b/apps/passport/app/routes/merge-identity/cancel.tsx @@ -0,0 +1,16 @@ +import { redirect, type LoaderFunction } from '@remix-run/cloudflare' + +import { getRollupReqFunctionErrorWrapper } from '@proofzero/utils/errors' + +import { destroyIdentityMergeState } from '~/session.server' + +export const loader: LoaderFunction = getRollupReqFunctionErrorWrapper( + async ({ request, context }) => { + const headers = new Headers() + headers.append( + 'Set-Cookie', + await destroyIdentityMergeState(request, context.env) + ) + return redirect('/authenticate/cancel', { headers }) + } +) diff --git a/apps/passport/app/routes/merge-identity/confirm.tsx b/apps/passport/app/routes/merge-identity/confirm.tsx new file mode 100644 index 0000000000..02a83572dc --- /dev/null +++ b/apps/passport/app/routes/merge-identity/confirm.tsx @@ -0,0 +1,234 @@ +import { useContext, useEffect } from 'react' +import { type LoaderFunction } from '@remix-run/cloudflare' +import { useFetcher, useLoaderData, Form } from '@remix-run/react' + +import { ImArrowDown } from 'react-icons/im' + +import { BadRequestError } from '@proofzero/errors' +import { getRollupReqFunctionErrorWrapper } from '@proofzero/utils/errors' + +import { Button } from '@proofzero/design-system/src/atoms/buttons/Button' +import { Text } from '@proofzero/design-system/src/atoms/text/Text' +import { ToastType, toast } from '@proofzero/design-system/src/atoms/toast' +import { ThemeContext } from '@proofzero/design-system/src/contexts/theme' + +import sideGraphics from '~/assets/auth-side-graphics.svg' +import dangerVector from '~/assets/warning.svg' + +import { getCoreClient } from '~/platform.server' + +import { + getIdentityMergeState, + getValidatedSessionContext, +} from '~/session.server' + +export const loader: LoaderFunction = getRollupReqFunctionErrorWrapper( + async ({ request, context }) => { + const mergeIdentityState = await getIdentityMergeState(request, context.env) + if (!mergeIdentityState) + throw new BadRequestError({ + message: 'missing merge identity state', + }) + + const { source, target } = mergeIdentityState + + const { jwt, identityURN } = await getValidatedSessionContext( + request, + context.authzQueryParams, + context.env, + context.traceSpan + ) + + if (identityURN !== target) + throw new BadRequestError({ + message: 'invalid merge identity state', + }) + + const coreClient = getCoreClient({ + context, + jwt, + }) + + const preview = await coreClient.identity.mergePreview.query({ + source, + target, + }) + + return { + source: preview.source, + target: preview.target, + } + } +) + +type LoaderData = { + source: UserProps + target: UserProps +} + +export default function Confirm() { + const { dark } = useContext(ThemeContext) + const { source, target } = useLoaderData() + const fetcher = useFetcher<{ error?: { message: string } }>() + + useEffect(() => { + if (fetcher.state !== 'idle') return + if (fetcher.type !== 'done') return + if (!fetcher.data) return + if (!fetcher.data.error) return + toast(ToastType.Error, fetcher.data.error, { duration: 2000 }) + }, [fetcher]) + + return ( + <> +
+
+
+
+
+
+
+ danger +
+
+ + Confirm Identity Merge + + + This action permanently transfers
+ + all accounts + {' '} + from one identity to other. +
+
+
+ + + +
+
+
+ +
+ + + +
+
+
+
+
+
+ + ) +} + +type UserProps = { + avatar: string + displayName: string + primaryAccountAlias: string + accounts: number + applications: number +} + +const User = ({ + avatar, + displayName, + primaryAccountAlias, + accounts, + applications, +}: UserProps) => { + return ( +
+
+
+ avatar +
+ + {displayName} + + + {primaryAccountAlias} + +
+
+
+
+
+ + Accounts:{' '} + + {accounts} + + +
+
+ + Applications:{' '} + + {applications} + + +
+
+
+
+
+ ) +} diff --git a/apps/passport/app/routes/merge-identity/index.tsx b/apps/passport/app/routes/merge-identity/index.tsx new file mode 100644 index 0000000000..acffcd3680 --- /dev/null +++ b/apps/passport/app/routes/merge-identity/index.tsx @@ -0,0 +1,6 @@ +import { redirect } from '@remix-run/cloudflare' +import type { LoaderFunction } from '@remix-run/cloudflare' + +export const loader: LoaderFunction = () => { + return redirect('/merge-identity/prompt') +} diff --git a/apps/passport/app/routes/merge-identity/merge.tsx b/apps/passport/app/routes/merge-identity/merge.tsx new file mode 100644 index 0000000000..3fadd722c6 --- /dev/null +++ b/apps/passport/app/routes/merge-identity/merge.tsx @@ -0,0 +1,71 @@ +import { redirect, type ActionFunction } from '@remix-run/cloudflare' + +import { BadRequestError, ConflictError } from '@proofzero/errors' +import { + getErrorCause, + getRollupReqFunctionErrorWrapper, +} from '@proofzero/utils/errors' + +import { getCoreClient } from '~/platform.server' + +import { + destroyIdentityMergeState, + getAuthzCookieParams, + getIdentityMergeState, + getValidatedSessionContext, +} from '~/session.server' + +import { getAuthzRedirectURL } from '~/utils/authenticate.server' + +export const action: ActionFunction = getRollupReqFunctionErrorWrapper( + async ({ request, context }) => { + const mergeIdentityState = await getIdentityMergeState(request, context.env) + if (!mergeIdentityState) + throw new BadRequestError({ + message: 'missing merge identity state', + }) + + const { source, target } = mergeIdentityState + + const { jwt, identityURN } = await getValidatedSessionContext( + request, + context.authzQueryParams, + context.env, + context.traceSpan + ) + + if (identityURN !== target) { + destroyIdentityMergeState(request, context.env) + throw new BadRequestError({ + message: 'invalid merge identity state', + }) + } + + const coreClient = getCoreClient({ + context, + jwt, + }) + + try { + await coreClient.identity.merge.mutate({ source, target }) + } catch (e) { + const error = getErrorCause(e) + if (error instanceof ConflictError) + return { + error: { + message: error.message, + }, + } + else throw error + } + + const headers = new Headers() + headers.append( + 'Set-Cookie', + await destroyIdentityMergeState(request, context.env) + ) + + const params = await getAuthzCookieParams(request, context.env) + return redirect(getAuthzRedirectURL(params), { headers }) + } +) diff --git a/apps/passport/app/routes/merge-identity/prompt.tsx b/apps/passport/app/routes/merge-identity/prompt.tsx new file mode 100644 index 0000000000..a58bd03552 --- /dev/null +++ b/apps/passport/app/routes/merge-identity/prompt.tsx @@ -0,0 +1,168 @@ +import { useContext, useState } from 'react' +import { redirect } from '@remix-run/cloudflare' +import type { ActionFunction, LoaderFunction } from '@remix-run/cloudflare' +import { useLoaderData, Form } from '@remix-run/react' + +import { Button } from '@proofzero/design-system/src/atoms/buttons/Button' +import { Text } from '@proofzero/design-system/src/atoms/text/Text' +import { ThemeContext } from '@proofzero/design-system/src/contexts/theme' + +import { BadRequestError } from '@proofzero/errors' +import { getRollupReqFunctionErrorWrapper } from '@proofzero/utils/errors' + +import { AccountURNSpace } from '@proofzero/urns/account' + +import sideGraphics from '~/assets/auth-side-graphics.svg' +import dangerVector from '~/assets/warning.svg' + +import { getIdentityMergeState } from '~/session.server' + +export const loader: LoaderFunction = getRollupReqFunctionErrorWrapper( + async ({ request, context }) => { + const mergeIdentityState = await getIdentityMergeState(request, context.env) + if (!mergeIdentityState) + throw new BadRequestError({ + message: 'missing merge identity state', + }) + + const { account } = mergeIdentityState + const alias = AccountURNSpace.componentizedParse(account).qcomponent?.alias + return { alias } + } +) + +export const action: ActionFunction = getRollupReqFunctionErrorWrapper( + async ({ request }) => { + const body = await request.formData() + const action = body.get('action') + if (action === 'cancel') return redirect('/merge-identity/cancel') + else if (action === 'confirm') return redirect('/merge-identity/confirm') + } +) + +type LoaderData = { + alias: string +} + +export default function Prompt() { + const { dark } = useContext(ThemeContext) + const { alias } = useLoaderData() + const [selectedOption, setSelectedOption] = useState('cancel') + + return ( + <> +
+
+
+
+
+
+
+
+ danger +
+
+
+ + Account{' '} + + {alias} + {' '} + is already connected to different identity. + + + How would you like to continue? + +
+
+
+
+ +
+ +
+
+
+
+
+
+
+ + ) +} + +type OptionProps = { + action: string + title: string + checked?: boolean + onChange: () => unknown + children?: JSX.Element +} + +const Option = ({ + action, + title, + checked = false, + onChange, + children, +}: OptionProps) => ( + +) diff --git a/apps/passport/app/session.server.ts b/apps/passport/app/session.server.ts index 4d729065d2..44696e1c68 100644 --- a/apps/passport/app/session.server.ts +++ b/apps/passport/app/session.server.ts @@ -19,8 +19,8 @@ import { encryptSession, decryptSession } from '@proofzero/utils/session' import { getCoreClient } from './platform.server' import type { TraceSpan } from '@proofzero/platform-middleware/trace' import { InternalServerError, UnauthorizedError } from '@proofzero/errors' -import { IdentityURNSpace } from '@proofzero/urns/identity' -import type { IdentityURN } from '@proofzero/urns/identity' +import { type AccountURN } from '@proofzero/urns/account' +import { type IdentityURN, IdentityURNSpace } from '@proofzero/urns/identity' import { FLASH_MESSAGE, FLASH_MESSAGE_KEY } from './utils/flashMessage.server' import { getCookieDomain } from './utils/cookie' @@ -29,6 +29,58 @@ export const InvalidSessionIdentityError = new UnauthorizedError({ message: 'Session identity is not valid', }) +// IDENTITY MERGE STATE + +export const getIdentityMergeState = async (request: Request, env: Env) => { + const storage = getIdentityMergeStateStorage(request, env) + const session = await storage.getSession(request.headers.get('Cookie')) + return { + account: session.get('account'), + source: session.get('source'), + target: session.get('target'), + } +} + +export const createIdentityMergeState = async ( + request: Request, + env: Env, + account: AccountURN, + source: IdentityURN, + target: IdentityURN +) => { + const storage = getIdentityMergeStateStorage(request, env) + const session = await storage.getSession() + session.set('account', account) + session.set('source', source) + session.set('target', target) + return storage.commitSession(session) +} + +export const destroyIdentityMergeState = async (request: Request, env: Env) => { + const storage = getIdentityMergeStateStorage(request, env) + const session = await getIdentityMergeStateSession(request, env) + return storage.destroySession(session) +} + +const getIdentityMergeStateSession = (request: Request, env: Env) => { + const storage = getIdentityMergeStateStorage(request, env) + return storage.getSession(request.headers.get('Cookie')) +} + +const getIdentityMergeStateStorage = (request: Request, env: Env) => { + return createCookieSessionStorage({ + cookie: { + name: '_rollup_identity_merge', + domain: getCookieDomain(request, env), + path: '/', + sameSite: 'lax', + secure: process.env.NODE_ENV == 'production', + httpOnly: true, + secrets: [env.SECRET_SESSION_SALT], + }, + }) +} + // FLASH SESSION const getFlashSessionStorage = (request: Request, env: Env) => { diff --git a/apps/passport/app/utils/authenticate.server.ts b/apps/passport/app/utils/authenticate.server.ts index 2b592f1e7c..4c6f5a4820 100644 --- a/apps/passport/app/utils/authenticate.server.ts +++ b/apps/passport/app/utils/authenticate.server.ts @@ -1,6 +1,5 @@ -import { AccountURNSpace } from '@proofzero/urns/account' -import type { AccountURN } from '@proofzero/urns/account' -import type { IdentityURN } from '@proofzero/urns/identity' +import { type AccountURN, AccountURNSpace } from '@proofzero/urns/account' +import { type IdentityURN, IdentityURNSpace } from '@proofzero/urns/identity' import { GrantType, ResponseType } from '@proofzero/types/authorization' @@ -20,6 +19,8 @@ import { createAuthenticatorSessionStorage, } from '~/auth.server' +import { createIdentityMergeState } from '~/session.server' + export const authenticateAccount = async ( account: AccountURN, identity: IdentityURN, @@ -42,6 +43,7 @@ export const authenticateAccount = async ( (['connect', 'reconnect'].includes(appData?.rollup_action) || appData?.rollup_action.startsWith('groupconnect')) ) { + const headers = new Headers() let result = undefined if ( @@ -49,17 +51,20 @@ export const authenticateAccount = async ( (appData.rollup_action === 'connect' || appData.rollup_action.startsWith('groupconnect')) ) { - const loggedInIdentity = parseJwt(jwt).sub - if (identity !== loggedInIdentity) { - result = 'ACCOUNT_CONNECT_ERROR' - } else { - result = 'ALREADY_CONNECTED_ERROR' + const source = identity + const target = parseJwt(jwt).sub + if (!target) result = 'ACCOUNT_CONNECT_ERROR' + else if (source === target) result = 'ALREADY_CONNECTED_ERROR' + else if (IdentityURNSpace.is(target) && source !== target) { + result = 'ACCOUNT_LINKED_ERROR' + headers.append( + 'Set-Cookie', + await createIdentityMergeState(request, env, account, source, target) + ) } } - const redirectURL = getAuthzRedirectURL(appData, result) - - return redirect(redirectURL) + return redirect(getAuthzRedirectURL(appData, result), { headers }) } const context = { env: { Core: env.Core }, traceSpan } diff --git a/packages/design-system/src/hooks/useConnectResult.tsx b/packages/design-system/src/hooks/useConnectResult.tsx index 2318f5536d..50c13f4bec 100644 --- a/packages/design-system/src/hooks/useConnectResult.tsx +++ b/packages/design-system/src/hooks/useConnectResult.tsx @@ -41,6 +41,8 @@ export default ( { duration: 2000 } ) break + case 'ACCOUNT_LINKED_ERROR': + break case 'CANCEL': toast( ToastType.Warning, diff --git a/packages/errors/index.ts b/packages/errors/index.ts index 18f1f28473..4ec623334c 100644 --- a/packages/errors/index.ts +++ b/packages/errors/index.ts @@ -4,6 +4,7 @@ export enum ERROR_CODES { UNAUTHORIZED = 'UNAUTHORIZED', FORBIDDEN = 'FORBIDDEN', NOT_FOUND = 'NOT_FOUND', + CONFLICT = 'CONFLICT', } export const HTTP_STATUS_CODES = { @@ -12,6 +13,7 @@ export const HTTP_STATUS_CODES = { [ERROR_CODES.UNAUTHORIZED]: 401, [ERROR_CODES.FORBIDDEN]: 403, [ERROR_CODES.NOT_FOUND]: 404, + [ERROR_CODES.CONFLICT]: 409, } export const ERROR_MESSAGES = { @@ -20,6 +22,7 @@ export const ERROR_MESSAGES = { [ERROR_CODES.UNAUTHORIZED]: 'not authorized', [ERROR_CODES.FORBIDDEN]: 'forbidden', [ERROR_CODES.NOT_FOUND]: 'not found', + [ERROR_CODES.CONFLICT]: 'conflict', } export type RollupErrorOptions = { @@ -87,6 +90,15 @@ export class NotFoundError extends RollupError { } } +export class ConflictError extends RollupError { + constructor(options?: RollupErrorOptions) { + if (!options) options = {} + options.code = options.code ?? ERROR_CODES.CONFLICT + options.message = options.message ?? ERROR_MESSAGES.CONFLICT + super(options) + } +} + export class InternalServerError extends RollupError { constructor(options?: RollupErrorOptions) { if (!options) options = {} diff --git a/packages/platform-middleware/jwt.ts b/packages/platform-middleware/jwt.ts index d9170356c3..6a2731e5ad 100644 --- a/packages/platform-middleware/jwt.ts +++ b/packages/platform-middleware/jwt.ts @@ -3,6 +3,9 @@ import { IdentityURNSpace, type IdentityURN } from '@proofzero/urns/identity' import { getAuthzTokenFromReq } from '@proofzero/utils' import { checkToken } from '@proofzero/utils/token' +import { type Environment } from '@proofzero/platform.core/src/types' +import { initIdentityNodeByName } from '@proofzero/platform.identity/src/nodes' + import { BaseMiddlewareFunction } from './types' export const AuthorizationTokenFromHeader: BaseMiddlewareFunction<{ @@ -20,15 +23,21 @@ export const AuthorizationTokenFromHeader: BaseMiddlewareFunction<{ } export const ValidateJWT: BaseMiddlewareFunction<{ + env: Environment token?: string -}> = ({ ctx, next }) => { +}> = async ({ ctx, next }) => { if (ctx.token) { const { sub: subject } = checkToken(ctx.token) if (subject && IdentityURNSpace.is(subject)) { + const identityNode = initIdentityNodeByName(subject, ctx.env.Identity) + const forwardIdentityURN = + await identityNode.class.getForwardIdentityURN() + const identityURN = forwardIdentityURN || subject + return next({ ctx: { ...ctx, - identityURN: subject, + identityURN, }, }) } diff --git a/packages/utils/errors.ts b/packages/utils/errors.ts index 0f35655999..0b55b931a5 100644 --- a/packages/utils/errors.ts +++ b/packages/utils/errors.ts @@ -11,6 +11,7 @@ import { UnauthorizedError, ForbiddenError, NotFoundError, + ConflictError, RollupError, ERROR_CODES, HTTP_STATUS_CODES, @@ -68,6 +69,7 @@ export const ROLLUP_ERROR_CLASS_BY_CODE = { [ERROR_CODES.UNAUTHORIZED]: UnauthorizedError, [ERROR_CODES.FORBIDDEN]: ForbiddenError, [ERROR_CODES.NOT_FOUND]: NotFoundError, + [ERROR_CODES.CONFLICT]: ConflictError, } export const getErrorCause = (error: unknown): RollupError | Error => { diff --git a/platform/account/src/jsonrpc/methods/deleteAccountNode.ts b/platform/account/src/jsonrpc/methods/deleteAccountNode.ts index a027953f74..101a0d4ba0 100644 --- a/platform/account/src/jsonrpc/methods/deleteAccountNode.ts +++ b/platform/account/src/jsonrpc/methods/deleteAccountNode.ts @@ -7,6 +7,7 @@ import { RollupError, ERROR_CODES } from '@proofzero/errors' import { IdentityURNSpace } from '@proofzero/urns/identity' import { AccountURN } from '@proofzero/urns/account' +import { initIdentityNodeByName } from '@proofzero/platform.identity/src/nodes' import { GetEdgesMethodOutput } from '@proofzero/platform.edges/src/jsonrpc/methods/getEdges' import type { Context } from '../../context' @@ -28,12 +29,20 @@ export const deleteAccountNodeMethod = async ({ input: DeleteAccountNodeParams ctx: Context }) => { - const { identityURN, forceDelete } = input + const { forceDelete } = input - if (!IdentityURNSpace.is(identityURN)) throw new Error('Invalid identity URN') + if (!IdentityURNSpace.is(input.identityURN)) + throw new Error('Invalid identity URN') if (!ctx.accountURN) throw new Error('missing account URN') if (!ctx.account) throw new Error('missing account node') + const identityNode = initIdentityNodeByName( + input.identityURN, + ctx.env.Identity + ) + const forwardIdentityURN = await identityNode.class.getForwardIdentityURN() + const identityURN = forwardIdentityURN || input.identityURN + const caller = router.createCaller(ctx) if (!forceDelete) { diff --git a/platform/account/src/jsonrpc/methods/setIdentity.ts b/platform/account/src/jsonrpc/methods/setIdentity.ts index e1cefb9c24..a2cc50c56e 100644 --- a/platform/account/src/jsonrpc/methods/setIdentity.ts +++ b/platform/account/src/jsonrpc/methods/setIdentity.ts @@ -31,6 +31,7 @@ export const setIdentityMethod = async ({ ctx: Context }): Promise => { const nodeClient = ctx.account + if (!nodeClient) throw new Error('missing node') const account = ctx.accountURN as AccountURN @@ -39,11 +40,21 @@ export const setIdentityMethod = async ({ throw new Error('Invalid identity URN') } + const stored = await nodeClient.class.getIdentity() + // Store the owning identity for the account node in the node // itself. - await nodeClient?.class.setIdentity(identity) + await nodeClient.class.setIdentity(identity) const caller = router.createCaller(ctx) + + if (stored) + await caller.edges.removeEdge({ + src: stored, + dst: account, + tag: EDGE_ACCOUNT, + }) + const linkResult = await caller.edges.makeEdge({ src: identity, dst: account, diff --git a/platform/authorization/src/jsonrpc/methods/exchangeToken.ts b/platform/authorization/src/jsonrpc/methods/exchangeToken.ts index d24dd2fca2..5e654688fc 100644 --- a/platform/authorization/src/jsonrpc/methods/exchangeToken.ts +++ b/platform/authorization/src/jsonrpc/methods/exchangeToken.ts @@ -306,6 +306,8 @@ const handleAuthorizationCode: ExchangeTokenMethod< issuer, }) + console.log(JSON.stringify({ accessToken, refreshToken, idToken }, null, 2)) + return { accessToken, refreshToken, idToken } } diff --git a/platform/authorization/src/jsonrpc/methods/getAuthorizedAppScopes.ts b/platform/authorization/src/jsonrpc/methods/getAuthorizedAppScopes.ts index 5ab92d19d4..5630c04937 100644 --- a/platform/authorization/src/jsonrpc/methods/getAuthorizedAppScopes.ts +++ b/platform/authorization/src/jsonrpc/methods/getAuthorizedAppScopes.ts @@ -7,6 +7,8 @@ import { IdentityURNSpace } from '@proofzero/urns/identity' import { appRouter } from '../router' import { getClaimValues } from '@proofzero/security/persona' +import { initIdentityNodeByName } from '@proofzero/platform.identity/src/nodes' + export const GetAuthorizedAppScopesMethodInput = z.object({ identityURN: inputValidators.IdentityURNInput, clientId: z.string().min(1), @@ -47,7 +49,14 @@ export const getAuthorizedAppScopesMethod = async ({ input: GetAuthorizedAppScopesMethodParams ctx: Context }): Promise => { - const { identityURN, clientId } = input + const { clientId } = input + + const identityNode = initIdentityNodeByName( + input.identityURN, + ctx.env.Identity + ) + const forwardIdentityURN = await identityNode.class.getForwardIdentityURN() + const identityURN = forwardIdentityURN || input.identityURN const nss = `${IdentityURNSpace.decode(identityURN)}@${clientId}` const urn = AuthorizationURNSpace.componentizedUrn(nss) diff --git a/platform/authorization/src/jsonrpc/methods/getPersonaData.ts b/platform/authorization/src/jsonrpc/methods/getPersonaData.ts index d321b42700..fdd3039e80 100644 --- a/platform/authorization/src/jsonrpc/methods/getPersonaData.ts +++ b/platform/authorization/src/jsonrpc/methods/getPersonaData.ts @@ -4,6 +4,8 @@ import { BadRequestError } from '@proofzero/errors' import { AuthorizationURNSpace } from '@proofzero/urns/authorization' import { IdentityURNSpace } from '@proofzero/urns/identity' +import { initIdentityNodeByName } from '@proofzero/platform.identity/src/nodes' + import { Context } from '../../context' import { initAuthorizationNodeByName } from '../../nodes' import { PersonaData } from '@proofzero/types/application' @@ -22,18 +24,25 @@ export const getPersonaDataMethod = async ({ input: z.infer ctx: Context }): Promise> => { - const { identityURN, clientId } = input + const { clientId } = input if (!clientId) throw new BadRequestError({ message: 'missing client id', }) - if (!IdentityURNSpace.is(identityURN)) + if (!IdentityURNSpace.is(input.identityURN)) throw new BadRequestError({ message: 'missing identity', }) + const identityNode = initIdentityNodeByName( + input.identityURN, + ctx.env.Identity + ) + const forwardIdentityURN = await identityNode.class.getForwardIdentityURN() + const identityURN = forwardIdentityURN || input.identityURN + const nss = `${IdentityURNSpace.decode(identityURN)}@${clientId}` const urn = AuthorizationURNSpace.componentizedUrn(nss) const authorizationNode = initAuthorizationNodeByName( diff --git a/platform/authorization/src/jsonrpc/methods/getUserInfo.ts b/platform/authorization/src/jsonrpc/methods/getUserInfo.ts index f5780ae416..76d28faa2a 100644 --- a/platform/authorization/src/jsonrpc/methods/getUserInfo.ts +++ b/platform/authorization/src/jsonrpc/methods/getUserInfo.ts @@ -6,6 +6,8 @@ import { AuthorizationURNSpace } from '@proofzero/urns/authorization' import { IdentityURNSpace } from '@proofzero/urns/identity' import { AuthorizationJWTPayload } from '@proofzero/types/authorization' +import { initIdentityNodeByName } from '@proofzero/platform.identity/src/nodes' + import { Context } from '../../context' import { getJWKS } from '../../jwk' import { initAuthorizationNodeByName } from '../../nodes' @@ -34,7 +36,6 @@ export const getUserInfoMethod = async ({ }): Promise> => { const token = input.access_token const jwt = decodeJwt(token) as AuthorizationJWTPayload - const identityURN = jwt.sub const [clientId] = jwt.aud const scope = jwt.scope.split(' ') @@ -43,11 +44,16 @@ export const getUserInfoMethod = async ({ message: 'missing client id in the aud claim', }) - if (!IdentityURNSpace.is(identityURN)) + if (!IdentityURNSpace.is(jwt.sub)) throw new BadRequestError({ message: 'missing identity in the sub claim', }) + const tokenIdentityNode = initIdentityNodeByName(jwt.sub, ctx.env.Identity) + const forwardIdentityURN = + await tokenIdentityNode.class.getForwardIdentityURN() + const identityURN = forwardIdentityURN || jwt.sub + const nss = `${IdentityURNSpace.decode(identityURN)}@${clientId}` const urn = AuthorizationURNSpace.componentizedUrn(nss) const authorizationNode = initAuthorizationNodeByName( diff --git a/platform/authorization/src/jsonrpc/methods/preauthorize.ts b/platform/authorization/src/jsonrpc/methods/preauthorize.ts index a3dcc64bda..7b8c69f018 100644 --- a/platform/authorization/src/jsonrpc/methods/preauthorize.ts +++ b/platform/authorization/src/jsonrpc/methods/preauthorize.ts @@ -9,6 +9,8 @@ import { appRouter } from '../router' import { AuthorizationURNSpace } from '@proofzero/urns/authorization' import { IdentityURNSpace } from '@proofzero/urns/identity' +import { initIdentityNodeByName } from '@proofzero/platform.identity/src/nodes' + export const PreAuthorizeMethodInput = z.object({ identity: IdentityURNInput, responseType: z.string(), @@ -42,9 +44,13 @@ export const preauthorizeMethod = async ({ }): Promise => { let preauthorized = false - const { identity, clientId, scope: requestedScope } = input + const { clientId, scope: requestedScope } = input + + const identityNode = initIdentityNodeByName(input.identity, ctx.env.Identity) + const forwardIdentityURN = await identityNode.class.getForwardIdentityURN() + const identityURN = forwardIdentityURN || input.identity - const nss = `${IdentityURNSpace.decode(identity)}@${clientId}` + const nss = `${IdentityURNSpace.decode(identityURN)}@${clientId}` const urn = AuthorizationURNSpace.componentizedUrn(nss) const authorizationNode = initAuthorizationNodeByName( urn, diff --git a/platform/authorization/src/jsonrpc/methods/revokeToken.ts b/platform/authorization/src/jsonrpc/methods/revokeToken.ts index bb54c1c04f..d141ea90da 100644 --- a/platform/authorization/src/jsonrpc/methods/revokeToken.ts +++ b/platform/authorization/src/jsonrpc/methods/revokeToken.ts @@ -6,6 +6,8 @@ import type { AuthorizationJWTPayload } from '@proofzero/types/authorization' import { AuthorizationURNSpace } from '@proofzero/urns/authorization' import { IdentityURNSpace } from '@proofzero/urns/identity' +import { initIdentityNodeByName } from '@proofzero/platform.identity/src/nodes' + import { Context } from '../../context' import { getJWKS } from '../../jwk' import { initAuthorizationNodeByName } from '../../nodes' @@ -51,7 +53,11 @@ export const revokeTokenMethod: RevokeTokenMethod = async ({ ctx, input }) => { if (clientId != payload.aud[0]) throw MismatchClientIdError if (!payload.sub) throw MissingSubjectError - const identityURN = payload.sub + let identityURN = payload.sub + const identityNode = initIdentityNodeByName(identityURN, ctx.env.Identity) + const forwardIdentityURN = await identityNode.class.getForwardIdentityURN() + if (forwardIdentityURN) identityURN = forwardIdentityURN + const nss = `${IdentityURNSpace.decode(identityURN)}@${clientId}` const urn = AuthorizationURNSpace.componentizedUrn(nss) const node = initAuthorizationNodeByName(urn, ctx.env.Authorization) diff --git a/platform/authorization/src/jsonrpc/methods/setExternalAppData.ts b/platform/authorization/src/jsonrpc/methods/setExternalAppData.ts index 73ffdff72b..86b055f1ca 100644 --- a/platform/authorization/src/jsonrpc/methods/setExternalAppData.ts +++ b/platform/authorization/src/jsonrpc/methods/setExternalAppData.ts @@ -52,6 +52,9 @@ export const usageGate = async ( let ownerNode if (IdentityURNSpace.is(identityURN)) { ownerNode = initIdentityNodeByName(identityURN, ctx.env.Identity) + const forwardIdentityURN = await ownerNode.class.getForwardIdentityURN() + if (forwardIdentityURN) + ownerNode = initIdentityNodeByName(forwardIdentityURN, ctx.env.Identity) } else if (IdentityGroupURNSpace.is(identityURN)) { ownerNode = initIdentityGroupNodeByName( identityURN, diff --git a/platform/authorization/src/jsonrpc/methods/verifyToken.ts b/platform/authorization/src/jsonrpc/methods/verifyToken.ts index 472932bf7d..09d766be55 100644 --- a/platform/authorization/src/jsonrpc/methods/verifyToken.ts +++ b/platform/authorization/src/jsonrpc/methods/verifyToken.ts @@ -7,6 +7,8 @@ import { AuthorizationURNSpace } from '@proofzero/urns/authorization' import { IdentityURNSpace } from '@proofzero/urns/identity' import { getErrorCause } from '@proofzero/utils/errors' +import { initIdentityNodeByName } from '@proofzero/platform.identity/src/nodes' + import { Context } from '../../context' import { getJWKS } from '../../jwk' import { initAuthorizationNodeByName } from '../../nodes' @@ -58,7 +60,11 @@ export const verifyTokenMethod: VerifyTokenMethod = async ({ ctx, input }) => { if (clientId && clientId != payload.aud[0]) throw MismatchClientIdError if (!payload.sub) throw MissingSubjectError - const identityURN = payload.sub + let identityURN = payload.sub + const identityNode = initIdentityNodeByName(identityURN, ctx.env.Identity) + const forwardIdentityURN = await identityNode.class.getForwardIdentityURN() + if (forwardIdentityURN) identityURN = forwardIdentityURN + const nss = `${IdentityURNSpace.decode(identityURN)}@${clientId}` const urn = AuthorizationURNSpace.componentizedUrn(nss) const node = initAuthorizationNodeByName(urn, ctx.env.Authorization) diff --git a/platform/authorization/src/jsonrpc/middleware/setAuthorizationNode.ts b/platform/authorization/src/jsonrpc/middleware/setAuthorizationNode.ts index bf8adca993..6a35379b41 100644 --- a/platform/authorization/src/jsonrpc/middleware/setAuthorizationNode.ts +++ b/platform/authorization/src/jsonrpc/middleware/setAuthorizationNode.ts @@ -5,6 +5,8 @@ import { IdentityURNSpace } from '@proofzero/urns/identity' import { AuthorizationJWTPayload } from '@proofzero/types/authorization' import { BaseMiddlewareFunction } from '@proofzero/platform-middleware/types' +import { initIdentityNodeByName } from '@proofzero/platform.identity/src/nodes' + import { initAuthorizationNodeByName } from '../../nodes' import { Context } from '../../context' @@ -17,17 +19,20 @@ export const setAuthorizationNode: BaseMiddlewareFunction = async ({ if (!ctx.token) throw new Error('No token found in middleware context') const jwt = decodeJwt(ctx.token) as AuthorizationJWTPayload - const identityURN = jwt.sub const [clientId] = jwt.aud if (!clientId) { throw new Error('missing client id in the aud claim') } - if (!IdentityURNSpace.is(identityURN)) { + if (!IdentityURNSpace.is(jwt.sub)) { throw new Error(`missing identity in the sub claim`) } + const identityNode = initIdentityNodeByName(jwt.sub, ctx.env.Identity) + const forwardIdentityURN = await identityNode.class.getForwardIdentityURN() + const identityURN = forwardIdentityURN || jwt.sub + const nss = `${IdentityURNSpace.decode(identityURN)}@${clientId}` const urn = AuthorizationURNSpace.componentizedUrn(nss) const authorizationNode = initAuthorizationNodeByName( diff --git a/platform/billing/src/jsonrpc/methods/cancelServicePlans.ts b/platform/billing/src/jsonrpc/methods/cancelServicePlans.ts index 23d3295bd6..aec42e5b37 100644 --- a/platform/billing/src/jsonrpc/methods/cancelServicePlans.ts +++ b/platform/billing/src/jsonrpc/methods/cancelServicePlans.ts @@ -26,6 +26,9 @@ export const cancelServicePlans = async ({ let ownerNode if (IdentityURNSpace.is(input.URN)) { ownerNode = initIdentityNodeByName(input.URN, ctx.env.Identity) + const forwardIdentityURN = await ownerNode.class.getForwardIdentityURN() + if (forwardIdentityURN) + ownerNode = initIdentityNodeByName(forwardIdentityURN, ctx.env.Identity) } else if (IdentityGroupURNSpace.is(input.URN)) { ownerNode = initIdentityGroupNodeByName(input.URN, ctx.env.IdentityGroup) } else { diff --git a/platform/billing/src/jsonrpc/methods/getEntitlements.ts b/platform/billing/src/jsonrpc/methods/getEntitlements.ts index 36ad6645b0..ba060c2472 100644 --- a/platform/billing/src/jsonrpc/methods/getEntitlements.ts +++ b/platform/billing/src/jsonrpc/methods/getEntitlements.ts @@ -45,6 +45,9 @@ export const getEntitlements = async ({ let ownerNode if (IdentityURNSpace.is(input.URN)) { ownerNode = initIdentityNodeByName(input.URN, ctx.env.Identity) + const forwardIdentityURN = await ownerNode.class.getForwardIdentityURN() + if (forwardIdentityURN) + ownerNode = initIdentityNodeByName(forwardIdentityURN, ctx.env.Identity) } else if (IdentityGroupURNSpace.is(input.URN)) { ownerNode = initIdentityGroupNodeByName(input.URN, ctx.env.IdentityGroup) } else { diff --git a/platform/billing/src/jsonrpc/methods/stripePaymentData.ts b/platform/billing/src/jsonrpc/methods/stripePaymentData.ts index 6fb2d105ee..e88053b5e3 100644 --- a/platform/billing/src/jsonrpc/methods/stripePaymentData.ts +++ b/platform/billing/src/jsonrpc/methods/stripePaymentData.ts @@ -45,6 +45,9 @@ export const getStripePaymentData = async ({ let ownerNode if (IdentityURNSpace.is(input.URN)) { ownerNode = initIdentityNodeByName(input.URN, ctx.env.Identity) + const forwardIdentityURN = await ownerNode.class.getForwardIdentityURN() + if (forwardIdentityURN) + ownerNode = initIdentityNodeByName(forwardIdentityURN, ctx.env.Identity) } else if (IdentityGroupURNSpace.is(input.URN)) { ownerNode = initIdentityGroupNodeByName(input.URN, ctx.env.IdentityGroup) } else { @@ -76,6 +79,9 @@ export const setStripePaymentData = async ({ let ownerNode if (IdentityURNSpace.is(input.URN)) { ownerNode = initIdentityNodeByName(input.URN, ctx.env.Identity) + const forwardIdentityURN = await ownerNode.class.getForwardIdentityURN() + if (forwardIdentityURN) + ownerNode = initIdentityNodeByName(forwardIdentityURN, ctx.env.Identity) } else if (IdentityGroupURNSpace.is(input.URN)) { ownerNode = initIdentityGroupNodeByName(input.URN, ctx.env.IdentityGroup) } else { diff --git a/platform/billing/src/jsonrpc/methods/updateEntitlements.ts b/platform/billing/src/jsonrpc/methods/updateEntitlements.ts index 39bbb47d89..dba8bd227e 100644 --- a/platform/billing/src/jsonrpc/methods/updateEntitlements.ts +++ b/platform/billing/src/jsonrpc/methods/updateEntitlements.ts @@ -32,6 +32,9 @@ export const updateEntitlements = async ({ let ownerNode if (IdentityURNSpace.is(input.URN)) { ownerNode = initIdentityNodeByName(input.URN, ctx.env.Identity) + const forwardIdentityURN = await ownerNode.class.getForwardIdentityURN() + if (forwardIdentityURN) + ownerNode = initIdentityNodeByName(forwardIdentityURN, ctx.env.Identity) } else if (IdentityGroupURNSpace.is(input.URN)) { ownerNode = initIdentityGroupNodeByName(input.URN, ctx.env.IdentityGroup) } else { diff --git a/platform/identity/package.json b/platform/identity/package.json index b2a2610177..943912f03e 100644 --- a/platform/identity/package.json +++ b/platform/identity/package.json @@ -33,6 +33,7 @@ "@proofzero/utils": "workspace:*", "@trpc/server": "10.8.1", "do-proxy": "1.3.3", + "lodash": "4.17.21", "ts-set-utils": "0.2.0", "typed-json-rpc": "1.1.0", "urns": "0.6.0", diff --git a/platform/identity/src/jsonrpc/methods/getAuthorizedApps.ts b/platform/identity/src/jsonrpc/methods/getAuthorizedApps.ts index 7d020298e1..bc58678130 100644 --- a/platform/identity/src/jsonrpc/methods/getAuthorizedApps.ts +++ b/platform/identity/src/jsonrpc/methods/getAuthorizedApps.ts @@ -45,6 +45,8 @@ export const getAuthorizedAppsMethod = async ({ if (!ctx.identityURN) throw new UnauthorizedError({ message: 'identity not found' }) + if (ctx.identityURN !== input.identity) throw new UnauthorizedError() + const caller = router.createCaller(ctx) const edgesResult = await caller.edges.getEdges({ query: { diff --git a/platform/identity/src/jsonrpc/methods/getProfile.ts b/platform/identity/src/jsonrpc/methods/getProfile.ts index 1c87020d68..25e1df9f2f 100644 --- a/platform/identity/src/jsonrpc/methods/getProfile.ts +++ b/platform/identity/src/jsonrpc/methods/getProfile.ts @@ -30,7 +30,11 @@ export const getProfileMethod = async ({ input: GetProfileParams ctx: Context }): Promise => { - const node = initIdentityNodeByName(input.identity, ctx.env.Identity) + let node = initIdentityNodeByName(input.identity, ctx.env.Identity) + const forwardIdentityURN = await node.class.getForwardIdentityURN() + if (forwardIdentityURN) + node = initIdentityNodeByName(forwardIdentityURN, ctx.env.Identity) + const caller = appRouter.createCaller(ctx) const [profile, accounts] = await Promise.all([ diff --git a/platform/identity/src/jsonrpc/methods/hasAccounts.ts b/platform/identity/src/jsonrpc/methods/hasAccounts.ts index 7de38c0048..1af746f41b 100644 --- a/platform/identity/src/jsonrpc/methods/hasAccounts.ts +++ b/platform/identity/src/jsonrpc/methods/hasAccounts.ts @@ -1,6 +1,8 @@ import * as set from 'ts-set-utils' import { z } from 'zod' +import { UnauthorizedError } from '@proofzero/errors' + import { router } from '@proofzero/platform.core' import { inputValidators } from '@proofzero/platform-middleware' @@ -39,9 +41,8 @@ export const hasAccountsMethod = async ({ input: HasAccountsInput ctx: Context }): Promise => { - if (input.identity !== ctx.identityURN) { - throw Error('Invalid identity input') - } + if (ctx.identityURN !== input.identity) throw new UnauthorizedError() + // Return the list of edges between the identity node and any // account nodes. Don't filter the accounts by type, we want them // all (the total number is normally going to be small). diff --git a/platform/identity/src/jsonrpc/methods/identity-groups/hasIdentityGroupPermissions.ts b/platform/identity/src/jsonrpc/methods/identity-groups/hasIdentityGroupPermissions.ts index fd157bbca7..cbd87c275c 100644 --- a/platform/identity/src/jsonrpc/methods/identity-groups/hasIdentityGroupPermissions.ts +++ b/platform/identity/src/jsonrpc/methods/identity-groups/hasIdentityGroupPermissions.ts @@ -7,7 +7,10 @@ import { router } from '@proofzero/platform.core' import { EDGE_MEMBER_OF_IDENTITY_GROUP } from '@proofzero/types/graph' import { Context } from '../../../context' -import { initIdentityGroupNodeByName } from '../../../nodes' +import { + initIdentityNodeByName, + initIdentityGroupNodeByName, +} from '../../../nodes' export const HasIdentityGroupPermissionsInputSchema = z.object({ identityURN: IdentityURNInput, @@ -34,10 +37,17 @@ export const hasIdentityGroupPermissions = async ({ }): Promise => { const caller = router.createCaller(ctx) + const identityNode = initIdentityNodeByName( + input.identityURN, + ctx.env.Identity + ) + const forwardIdentityURN = await identityNode.class.getForwardIdentityURN() + const identityURN = forwardIdentityURN || input.identityURN + const { edges } = await caller.edges.getEdges({ query: { src: { - baseUrn: input.identityURN, + baseUrn: identityURN, }, tag: EDGE_MEMBER_OF_IDENTITY_GROUP, dst: { @@ -50,7 +60,7 @@ export const hasIdentityGroupPermissions = async ({ input.identityGroupURN, ctx.env.IdentityGroup ) - const { error } = await DO.class.validateAdmin(input.identityURN) + const { error } = await DO.class.validateAdmin(identityURN) return { read: edges.length > 0, diff --git a/platform/identity/src/jsonrpc/methods/isValid.ts b/platform/identity/src/jsonrpc/methods/isValid.ts index ce48461bc7..5a8e8f5998 100644 --- a/platform/identity/src/jsonrpc/methods/isValid.ts +++ b/platform/identity/src/jsonrpc/methods/isValid.ts @@ -1,6 +1,8 @@ import { z } from 'zod' import { Context } from '../../context' +import { checkToken } from '@proofzero/utils/token' + import type { AccountList } from '../../types' import type { IdentityURN } from '@proofzero/urns/identity' @@ -19,6 +21,11 @@ export const isValidMethod = async ({ }: { ctx: Context }): Promise => { + if (ctx.token) { + const { sub: subject } = checkToken(ctx.token) + if (subject !== ctx.identityURN) return false + } + //Relies on injectIdentityNode middleware const profile = ctx.identityNode?.class.getProfile() const accounts = ctx.identityNode?.class.getAccounts() diff --git a/platform/identity/src/jsonrpc/methods/merge-preview.ts b/platform/identity/src/jsonrpc/methods/merge-preview.ts new file mode 100644 index 0000000000..38b2ab6658 --- /dev/null +++ b/platform/identity/src/jsonrpc/methods/merge-preview.ts @@ -0,0 +1,135 @@ +import { z } from 'zod' + +import { InternalServerError } from '@proofzero/errors' + +import { router } from '@proofzero/platform.core' +import { EDGE_ACCOUNT } from '@proofzero/platform.account/src/constants' +import { EDGE_AUTHORIZES } from '@proofzero/platform.authorization/src/constants' +import { IdentityURNInput } from '@proofzero/platform-middleware/inputValidators' + +import { Context } from '../../context' +import { initIdentityNodeByName } from '../../nodes' + +export const MergePreviewInput = z.object({ + source: IdentityURNInput, + target: IdentityURNInput, +}) +type MergePreviewInput = z.infer + +const IdentitySummary = z.object({ + avatar: z.string(), + displayName: z.string(), + primaryAccountAlias: z.string(), + accounts: z.number(), + applications: z.number(), +}) + +export const MergePreviewOutput = z.object({ + source: IdentitySummary.optional(), + target: IdentitySummary.optional(), +}) +type MergePreviewOutput = z.infer + +type MergePreviewParams = { + input: MergePreviewInput + ctx: Context +} + +type MergePreviewResult = MergePreviewOutput + +interface MergePreviewMethod { + (params: MergePreviewParams): Promise +} + +export const mergePreviewMethod: MergePreviewMethod = async ({ + input, + ctx, +}) => { + const { source, target } = input + + const sourceIdentityNode = initIdentityNodeByName(source, ctx.env.Identity) + const sourceIdentityProfile = await sourceIdentityNode.class.getProfile() + const targetIdentityNode = initIdentityNodeByName(target, ctx.env.Identity) + const targetIdentityProfile = await targetIdentityNode.class.getProfile() + + if (!sourceIdentityProfile) + throw new InternalServerError({ + message: 'missing source identity profile', + }) + + if (!targetIdentityProfile) + throw new InternalServerError({ + message: 'missing target identity profile', + }) + + const caller = router.createCaller(ctx) + + let sourceIdentityPrimaryAccountProfile + if (sourceIdentityProfile.primaryAccountURN) { + ;[sourceIdentityPrimaryAccountProfile] = + await caller.account.getAccountProfileBatch([ + sourceIdentityProfile.primaryAccountURN, + ]) + } + + let targetIdentityPrimaryAccountProfile + if (targetIdentityProfile.primaryAccountURN) { + ;[targetIdentityPrimaryAccountProfile] = + await caller.account.getAccountProfileBatch([ + targetIdentityProfile.primaryAccountURN, + ]) + } + + const { edges: sourceIdentityAccountEdges } = await caller.edges.getEdges({ + query: { + src: { baseUrn: source }, + tag: EDGE_ACCOUNT, + }, + }) + + const { edges: sourceIdentityApplicationEdges } = await caller.edges.getEdges( + { + query: { + src: { baseUrn: source }, + tag: EDGE_AUTHORIZES, + }, + } + ) + + const { edges: targetIdentityAccountEdges } = await caller.edges.getEdges({ + query: { + src: { baseUrn: target }, + tag: EDGE_ACCOUNT, + }, + }) + + const { edges: targetIdentityApplicationEdges } = await caller.edges.getEdges( + { + query: { + src: { baseUrn: target }, + tag: EDGE_AUTHORIZES, + }, + } + ) + + return { + source: sourceIdentityProfile + ? { + avatar: sourceIdentityProfile.pfp?.image || '', + displayName: sourceIdentityProfile.displayName, + primaryAccountAlias: sourceIdentityPrimaryAccountProfile?.title || '', + accounts: sourceIdentityAccountEdges.length, + applications: sourceIdentityApplicationEdges.length, + } + : undefined, + target: targetIdentityProfile + ? { + avatar: targetIdentityProfile.pfp?.image || '', + displayName: targetIdentityProfile.displayName, + primaryAccountAlias: targetIdentityPrimaryAccountProfile?.title || '', + accounts: targetIdentityAccountEdges.length, + applications: targetIdentityApplicationEdges.length, + } + : undefined, + } +} diff --git a/platform/identity/src/jsonrpc/methods/merge.ts b/platform/identity/src/jsonrpc/methods/merge.ts new file mode 100644 index 0000000000..88f4c9a1a1 --- /dev/null +++ b/platform/identity/src/jsonrpc/methods/merge.ts @@ -0,0 +1,406 @@ +import { intersection } from 'lodash' +import { z } from 'zod' + +import { router } from '@proofzero/platform.core' +import { EDGE_ACCOUNT } from '@proofzero/platform.account/src/constants' +import { EDGE_AUTHORIZES } from '@proofzero/platform.authorization/src/constants' +import { EDGE_APPLICATION } from '@proofzero/platform.starbase/src/types' +import { initAuthorizationNodeByName } from '@proofzero/platform.authorization/src/nodes' +import { IdentityURNInput } from '@proofzero/platform-middleware/inputValidators' + +import { ConflictError } from '@proofzero/errors' +import { + EDGE_MEMBER_OF_IDENTITY_GROUP, + EDGE_PAYS_APP, +} from '@proofzero/types/graph' + +import { type AnyURN } from '@proofzero/urns' +import { type AccountURN } from '@proofzero/urns/account' +import { + AuthorizationURNSpace, + type AuthorizationURN, +} from '@proofzero/urns/authorization' +import { IdentityURNSpace, type IdentityURN } from '@proofzero/urns/identity' + +import { Context } from '../../context' +import { initIdentityNodeByName } from '../../nodes' + +export const MergeInput = z.object({ + source: IdentityURNInput, + target: IdentityURNInput, +}) +type MergeInput = z.infer + +export const MergeOutput = z.void() +type MergeOutput = z.infer + +type MergeParams = { + input: MergeInput + ctx: Context +} + +type MergeResult = MergeOutput + +interface MergeMethod { + (params: MergeParams): MergeResult +} + +export const mergeMethod: MergeMethod = async ({ input, ctx }) => { + const { source, target } = input + + await Promise.all([ + checkAuthorizationConflicts(source, target, ctx), + checkIdentityPays(source, ctx), + ]) + + await Promise.all([ + moveAccounts(source, target, ctx), + moveAuthorizations(source, target, ctx), + moveIdentityGroupEdges(source, target, ctx), + moveIdentityOwnsAppEdges(source, target, ctx), + moveIdentityPaysAppEdges(source, target, ctx), + ]) + await setIdentityForward(source, target, ctx) +} + +const checkAuthorizationConflicts = async ( + sourceIdentityURN: IdentityURN, + targetIdentityURN: IdentityURN, + ctx: Context +) => { + const caller = router.createCaller(ctx) + + const { edges: sourceIdentityAuthorizationEdges } = + await caller.edges.getEdges({ + query: { + src: { baseUrn: sourceIdentityURN }, + tag: EDGE_AUTHORIZES, + }, + }) + + const { edges: targetIdentityAuthorizationEdges } = + await caller.edges.getEdges({ + query: { + src: { baseUrn: targetIdentityURN }, + tag: EDGE_AUTHORIZES, + }, + }) + + const getClientIds = ( + edges: Array<{ + dst: { + baseUrn: AnyURN + } + }> + ): Array => { + return edges.map((e) => { + const nss = AuthorizationURNSpace.decode( + e.dst.baseUrn as AuthorizationURN + ) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [_, clientId] = nss.split('@') + return clientId + }) + } + + const sourceIdentityClientIds = getClientIds(sourceIdentityAuthorizationEdges) + const targetIdentityClientIds = getClientIds(targetIdentityAuthorizationEdges) + + const conflicts = intersection( + sourceIdentityClientIds, + targetIdentityClientIds + ) + + if (conflicts.length === 0) return + + throw new ConflictError({ + message: 'Identities have authorizations to same applications', + }) +} + +const checkIdentityPays = async (identityURN: IdentityURN, ctx: Context) => { + const caller = router.createCaller(ctx) + + const { edges } = await caller.edges.getEdges({ + query: { + src: { baseUrn: identityURN }, + tag: EDGE_PAYS_APP, + }, + }) + + if (edges.length === 0) return + + throw new ConflictError({ + message: + 'Source identity has a payment subscription.\ + Please transfer your application(s) to an identity group first.', + }) +} + +async function moveAccounts( + sourceIdentityURN: IdentityURN, + targetIdentityURN: IdentityURN, + ctx: Context +) { + const caller = router.createCaller(ctx) + const { edges } = await caller.edges.getEdges({ + query: { + src: { baseUrn: sourceIdentityURN }, + tag: EDGE_ACCOUNT, + }, + }) + + const urns = edges.map((e) => e.dst.baseUrn as AccountURN) + for (const account3RN of urns) { + const caller = router.createCaller({ ...ctx, account3RN }) + await caller.account.setIdentity(targetIdentityURN) + } +} + +const moveAuthorizations = async ( + sourceIdentityURN: IdentityURN, + targetIdentityURN: IdentityURN, + ctx: Context +) => { + const caller = router.createCaller(ctx) + const { edges } = await caller.edges.getEdges({ + query: { + src: { baseUrn: sourceIdentityURN }, + tag: EDGE_AUTHORIZES, + }, + }) + + const urns = edges.map((e) => { + const sourceAuthorizationURN = e.dst.baseUrn as AuthorizationURN + const sourceNSS = AuthorizationURNSpace.decode(sourceAuthorizationURN) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [_, clientId] = sourceNSS.split('@') + const targetNSS = `${IdentityURNSpace.decode( + targetIdentityURN + )}@${clientId}` + const targetAuthorizationURN = + AuthorizationURNSpace.componentizedUrn(targetNSS) + + return { + sourceAuthorizationURN, + targetAuthorizationURN, + } + }) + + await migrateAuthorizationNodes(urns, ctx) + await migrateAuthorizationEdges( + sourceIdentityURN, + targetIdentityURN, + urns, + ctx + ) +} + +type AuthorizationURNs = Array<{ + sourceAuthorizationURN: AuthorizationURN + targetAuthorizationURN: AuthorizationURN +}> + +const migrateAuthorizationNodes = async ( + urns: AuthorizationURNs, + ctx: Context +) => { + const nodes = urns.map( + ({ sourceAuthorizationURN, targetAuthorizationURN }) => ({ + source: initAuthorizationNodeByName( + sourceAuthorizationURN, + ctx.env.Authorization + ), + target: initAuthorizationNodeByName( + targetAuthorizationURN, + ctx.env.Authorization + ), + }) + ) + + return nodes.map(async ({ source, target }) => { + const storage = await source.storage.list() + await target.storage.put(Object.fromEntries(storage.entries())) + await source.storage.deleteAll() + }) +} + +const migrateAuthorizationEdges = async ( + sourceIdentityURN: IdentityURN, + targetIdentityURN: IdentityURN, + urns: AuthorizationURNs, + ctx: Context +) => { + const caller = router.createCaller(ctx) + return urns.map( + async ({ sourceAuthorizationURN, targetAuthorizationURN }) => { + await caller.edges.removeEdge({ + src: sourceIdentityURN, + tag: EDGE_AUTHORIZES, + dst: sourceAuthorizationURN, + }) + + await caller.edges.deleteNode({ urn: sourceAuthorizationURN }) + + const sourceNSS = AuthorizationURNSpace.decode(sourceAuthorizationURN) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [_, clientId] = sourceNSS.split('@') + + await caller.edges.makeEdge({ + src: targetIdentityURN, + tag: EDGE_AUTHORIZES, + dst: AuthorizationURNSpace.componentizedUrn( + AuthorizationURNSpace.decode(targetAuthorizationURN), + { client_id: clientId } + ), + }) + } + ) +} + +const moveIdentityOwnsAppEdges = async ( + sourceIdentityURN: IdentityURN, + targetIdentityURN: IdentityURN, + ctx: Context +) => { + const caller = router.createCaller(ctx) + const { edges } = await caller.edges.getEdges({ + query: { + src: { baseUrn: sourceIdentityURN }, + tag: EDGE_APPLICATION, + }, + }) + + await Promise.all( + edges.map(async (edge) => { + await caller.edges.removeEdge({ + src: edge.src.baseUrn, + tag: EDGE_APPLICATION, + dst: edge.dst.baseUrn, + }) + }) + ) + + const appURNs = edges.map((edge) => edge.dst.baseUrn) + await Promise.all( + appURNs.map(async (appURN) => { + const { edges } = await caller.edges.getEdges({ + query: { + src: { baseUrn: targetIdentityURN }, + tag: EDGE_APPLICATION, + dst: { baseUrn: appURN }, + }, + }) + + if (edges.length > 0) return + + await caller.edges.makeEdge({ + src: targetIdentityURN, + tag: EDGE_APPLICATION, + dst: appURN, + }) + }) + ) +} + +const moveIdentityGroupEdges = async ( + sourceIdentityURN: IdentityURN, + targetIdentityURN: IdentityURN, + ctx: Context +) => { + const caller = router.createCaller(ctx) + const { edges } = await caller.edges.getEdges({ + query: { + src: { baseUrn: sourceIdentityURN }, + tag: EDGE_MEMBER_OF_IDENTITY_GROUP, + }, + }) + + await Promise.all( + edges.map(async (edge) => { + await caller.edges.removeEdge({ + src: edge.src.baseUrn, + tag: EDGE_MEMBER_OF_IDENTITY_GROUP, + dst: edge.dst.baseUrn, + }) + }) + ) + + const identityGroupURNs = edges.map((edge) => edge.dst.baseUrn) + await Promise.all( + identityGroupURNs.map(async (identityGroupURN) => { + const { edges } = await caller.edges.getEdges({ + query: { + src: { baseUrn: targetIdentityURN }, + tag: EDGE_MEMBER_OF_IDENTITY_GROUP, + dst: { baseUrn: identityGroupURN }, + }, + }) + + if (edges.length > 0) return + + await caller.edges.makeEdge({ + src: targetIdentityURN, + tag: EDGE_MEMBER_OF_IDENTITY_GROUP, + dst: identityGroupURN, + }) + }) + ) +} + +const moveIdentityPaysAppEdges = async ( + sourceIdentityURN: IdentityURN, + targetIdentityURN: IdentityURN, + ctx: Context +) => { + const caller = router.createCaller(ctx) + const { edges } = await caller.edges.getEdges({ + query: { + src: { baseUrn: sourceIdentityURN }, + tag: EDGE_PAYS_APP, + }, + }) + + await Promise.all( + edges.map(async (edge) => { + await caller.edges.removeEdge({ + src: edge.src.baseUrn, + tag: EDGE_PAYS_APP, + dst: edge.dst.baseUrn, + }) + }) + ) + + const appURNs = edges.map((edge) => edge.dst.baseUrn) + await Promise.all( + appURNs.map(async (appURN) => { + const { edges } = await caller.edges.getEdges({ + query: { + src: { baseUrn: targetIdentityURN }, + tag: EDGE_PAYS_APP, + dst: { baseUrn: appURN }, + }, + }) + + if (edges.length > 0) return + + await caller.edges.makeEdge({ + src: targetIdentityURN, + tag: EDGE_PAYS_APP, + dst: appURN, + }) + }) + ) +} + +const setIdentityForward = ( + sourceIdentityURN: IdentityURN, + targetIdentityURN: IdentityURN, + ctx: Context +) => { + const sourceIdentityNode = initIdentityNodeByName( + sourceIdentityURN, + ctx.env.Identity + ) + return sourceIdentityNode.class.setForwardIdentityURN(targetIdentityURN) +} diff --git a/platform/identity/src/jsonrpc/router.ts b/platform/identity/src/jsonrpc/router.ts index 968601a0eb..46db7df949 100644 --- a/platform/identity/src/jsonrpc/router.ts +++ b/platform/identity/src/jsonrpc/router.ts @@ -102,6 +102,12 @@ import { patchProfileFieldsMethod, } from './methods/patchProfileFields' import { resetProfileFieldsMethod } from './methods/resetProfileFields' +import { mergeMethod, MergeInput, MergeOutput } from './methods/merge' +import { + mergePreviewMethod, + MergePreviewInput, + MergePreviewOutput, +} from './methods/merge-preview' const t = initTRPC.context().create({ errorFormatter }) @@ -111,7 +117,10 @@ export const injectIdentityNode = t.middleware(async ({ ctx, next }) => { if (!identityURN) throw new UnauthorizedError({ message: 'No identityURN in context' }) - const identityNode = initIdentityNodeByName(identityURN, ctx.env.Identity) + let identityNode = initIdentityNodeByName(identityURN, ctx.env.Identity) + const forwardIdentityURN = await identityNode.class.getForwardIdentityURN() + if (forwardIdentityURN) + identityNode = initIdentityNodeByName(forwardIdentityURN, ctx.env.Identity) return next({ ctx: { @@ -306,4 +315,24 @@ export const appRouter = t.router({ .use(RequireIdentity) .use(injectIdentityNode) .mutation(resetProfileFieldsMethod), + merge: t.procedure + .use(LogUsage) + .use(Analytics) + .use(AuthorizationTokenFromHeader) + .use(ValidateJWT) + .use(RequireIdentity) + .use(injectIdentityNode) + .input(MergeInput) + .output(MergeOutput) + .mutation(mergeMethod), + mergePreview: t.procedure + .use(LogUsage) + .use(Analytics) + .use(AuthorizationTokenFromHeader) + .use(ValidateJWT) + .use(RequireIdentity) + .use(injectIdentityNode) + .input(MergePreviewInput) + .output(MergePreviewOutput) + .query(mergePreviewMethod), }) diff --git a/platform/identity/src/nodes/identity-group.ts b/platform/identity/src/nodes/identity-group.ts index 3bd249e4f0..c718589d02 100644 --- a/platform/identity/src/nodes/identity-group.ts +++ b/platform/identity/src/nodes/identity-group.ts @@ -17,6 +17,10 @@ import { import { IdentityURN } from '@proofzero/urns/identity' import { DOProxy } from 'do-proxy' import { NodeMethodReturnValue } from '@proofzero/types/node' + +import { Environment } from '@proofzero/platform.core' + +import { initIdentityNodeByName } from '.' import { IDENTITY_GROUP_OPTIONS } from '../constants' export type InviteMemberInput = { @@ -36,10 +40,12 @@ export type ClearInvitationInput = { export default class IdentityGroup extends DOProxy { declare state: DurableObjectState + declare env: Environment - constructor(state: DurableObjectState) { - super(state) + constructor(state: DurableObjectState, env: Environment) { + super(state, env) this.state = state + this.env = env } async getServicePlans(): Promise { @@ -189,11 +195,16 @@ export default class IdentityGroup extends DOProxy { } async getOrderedMembers(): Promise { - const orderedMembers = await this.state.storage.get( - 'orderedMembers' + const orderedMembers = + (await this.state.storage.get('orderedMembers')) || [] + + return Promise.all( + orderedMembers.map(async (urn) => { + const node = initIdentityNodeByName(urn, this.env.Identity) + const forwardURN = await node.class.getForwardIdentityURN() + return forwardURN || urn + }) ) - - return orderedMembers || [] } async setOrderedMembers(members: IdentityURN[]): Promise { diff --git a/platform/identity/src/nodes/identity.ts b/platform/identity/src/nodes/identity.ts index f03a92759f..25f6ac9423 100644 --- a/platform/identity/src/nodes/identity.ts +++ b/platform/identity/src/nodes/identity.ts @@ -6,6 +6,7 @@ import { ServicePlans, } from '@proofzero/types/billing' import { RollupError } from '@proofzero/errors' +import { IdentityURN } from '@proofzero/urns/identity' export default class Identity extends DOProxy { declare state: DurableObjectState @@ -85,4 +86,15 @@ export default class Identity extends DOProxy { await this.state.storage.put('stripePaymentData', paymentData) } + + async getForwardIdentityURN(): Promise { + return this.state.storage.get('forwardIdentityURN') + } + + async setForwardIdentityURN(forwardIdentityURN: IdentityURN): Promise { + return this.state.storage.put( + 'forwardIdentityURN', + forwardIdentityURN + ) + } } diff --git a/yarn.lock b/yarn.lock index e14aa8b421..b8b5c08092 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6918,6 +6918,7 @@ __metadata: do-proxy: 1.3.3 eslint: 8.28.0 eslint-config-prettier: 8.5.0 + lodash: 4.17.21 npm-run-all: 4.1.5 prettier: 2.7.1 ts-set-utils: 0.2.0