diff --git a/api-specs/openrpc-user-api.json b/api-specs/openrpc-user-api.json index 06da0222f..ca7a5be89 100644 --- a/api-specs/openrpc-user-api.json +++ b/api-specs/openrpc-user-api.json @@ -797,13 +797,18 @@ "type": "object", "additionalProperties": false, "properties": { + "origin": { + "title": "origin", + "type": "string", + "description": "origin of the dApp establishing a session" + }, "networkId": { "title": "networkId", "type": "string", "description": "Network Id" } }, - "required": ["networkId"] + "required": ["networkId", "origin"] } } ], diff --git a/core/types/src/index.ts b/core/types/src/index.ts index fd489cdf2..fa48dd475 100644 --- a/core/types/src/index.ts +++ b/core/types/src/index.ts @@ -91,6 +91,9 @@ export enum WalletEvent { // Auth events SPLICE_WALLET_IDP_AUTH_SUCCESS = 'SPLICE_WALLET_IDP_AUTH_SUCCESS', SPLICE_WALLET_LOGOUT = 'SPLICE_WALLET_LOGOUT', + // Other + SPLICE_WALLET_BROADCAST_ORIGIN = 'SPLICE_WALLET_BROADCAST_ORIGIN', // Used by the dApp (parent) to broadcast its origin down to the wallet (child) for cross-origin communication + SPLICE_WALLET_BROADCAST_ORIGIN_ACK = 'SPLICE_WALLET_BROADCAST_ORIGIN_ACK', // Used by the wallet (child) to acknowledge receipt of the dApp's origin for cross-origin communication } export type SpliceMessageEvent = MessageEvent @@ -122,7 +125,7 @@ export const SpliceMessage = z.discriminatedUnion('type', [ }), z.object({ type: z.literal(WalletEvent.SPLICE_WALLET_EXT_OPEN), - url: z.string().url(), + url: z.url(), target: SpliceTarget.optional(), }), z.object({ @@ -130,6 +133,13 @@ export const SpliceMessage = z.discriminatedUnion('type', [ token: z.string(), sessionId: z.string(), }), + z.object({ + type: z.literal(WalletEvent.SPLICE_WALLET_BROADCAST_ORIGIN), + origin: z.url(), + }), + z.object({ + type: z.literal(WalletEvent.SPLICE_WALLET_BROADCAST_ORIGIN_ACK), + }), ]) export type SpliceMessage = z.infer diff --git a/core/wallet-store-sql/src/migrations/015-add-origin-field-session.ts b/core/wallet-store-sql/src/migrations/015-add-origin-field-session.ts new file mode 100644 index 000000000..03c1ddd35 --- /dev/null +++ b/core/wallet-store-sql/src/migrations/015-add-origin-field-session.ts @@ -0,0 +1,15 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Kysely } from 'kysely' +import { DB } from '../schema.js' + +export async function up(db: Kysely): Promise { + console.log('Adding origin column to networks table') + + await db.schema.alterTable('sessions').addColumn('origin', 'text').execute() +} + +export async function down(db: Kysely): Promise { + await db.schema.alterTable('sessions').dropColumn('origin').execute() +} diff --git a/core/wallet-store-sql/src/schema.ts b/core/wallet-store-sql/src/schema.ts index 736090b9e..9aca224a0 100644 --- a/core/wallet-store-sql/src/schema.ts +++ b/core/wallet-store-sql/src/schema.ts @@ -114,6 +114,7 @@ interface MessageRawTable { interface SessionTable { id: string + origin: string network: string accessToken: string userId: UserId @@ -241,6 +242,7 @@ export const toSession = (table: SessionTable): Session => { return { id: table.id, network: table.network, + origin: table.origin, accessToken: table.accessToken, userId: table.userId, } diff --git a/core/wallet-store-sql/src/store-sql.ts b/core/wallet-store-sql/src/store-sql.ts index f3f5da765..dbd59d9fd 100644 --- a/core/wallet-store-sql/src/store-sql.ts +++ b/core/wallet-store-sql/src/store-sql.ts @@ -383,16 +383,28 @@ export class StoreSql implements BaseStore, AuthAware { async setSession(session: Session): Promise { const userId = this.assertConnected() + + if (!session.origin) { + throw new Error('Session origin is required') + } + await this.db.transaction().execute(async (trx) => { const deleted = await trx .deleteFrom('sessions') - .where('userId', '=', userId) + .where((eb) => + eb.and([ + eb('userId', '=', userId), + eb('origin', '=', session.origin), + ]) + ) .execute() this.logger.debug(deleted, 'Deleted old session') + const inserted = await trx .insertInto('sessions') .values({ ...session, userId }) .execute() + this.logger.debug(inserted, 'Inserted new session') }) } diff --git a/core/wallet-store/src/Store.ts b/core/wallet-store/src/Store.ts index 03cb9a05d..3176fb141 100644 --- a/core/wallet-store/src/Store.ts +++ b/core/wallet-store/src/Store.ts @@ -74,6 +74,7 @@ export interface Wallet { export interface Session { id: string + origin: string network: string accessToken: string userId?: string @@ -148,9 +149,21 @@ export interface Store { ): Promise // Session methods - getSession(): Promise + /** + * getSession is keyed by the accessToken, which is unique per session. It retrieves the session associated with the provided accessToken. + * @param accessToken The access token associated with the session to retrieve. + * @returns A Promise that resolves to the Session object if found, or undefined if no session exists for the given accessToken. + */ + getSession(accessToken: string): Promise + setSession(session: Session): Promise - removeSession(): Promise + + /** + * removeSession is keyed by the accessToken, which is unique per session. It removes the session associated with the provided accessToken. + * @param accessToken The access token associated with the session to remove. + * @returns A Promise that resolves when the session has been removed. + */ + removeSession(accessToken: string): Promise // IDP methods getIdp(idpId: string): Promise diff --git a/core/wallet-ui-components/src/windows/popup.ts b/core/wallet-ui-components/src/windows/popup.ts index efb84dd49..4016cf1f6 100644 --- a/core/wallet-ui-components/src/windows/popup.ts +++ b/core/wallet-ui-components/src/windows/popup.ts @@ -1,6 +1,12 @@ // Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { + isSpliceMessageEvent, + SpliceMessage, + WalletEvent, +} from '@canton-network/core-types' + interface PopupOptions { title?: string target?: string @@ -57,7 +63,41 @@ class PopupInstance { urlOrComponent instanceof URL ) { const win = PopupInstance.getInstance() - win.location.href = urlOrComponent.toString() + const url = urlOrComponent.toString() + const childOrigin = new URL(url).origin + win.location.href = url + + const message: SpliceMessage = { + type: WalletEvent.SPLICE_WALLET_BROADCAST_ORIGIN, + origin: window.location.origin, + } + + // due to the asynchronicity when sending the postMessage immediately after redirecting, + // there is a chance that the child window has not yet loaded, + // and does not an event listener established yet. Therefore, + // we repeatedly poll until the child window sends back an acknowledgment message. + const handleMessage = (event: MessageEvent) => { + if (!isSpliceMessageEvent(event)) return + if ( + event.data.type !== + WalletEvent.SPLICE_WALLET_BROADCAST_ORIGIN_ACK + ) + return + if (childOrigin !== event.origin) return + + console.log('Received acknowledgment from child window:', event) + + clearInterval(originPoller) + window.removeEventListener('message', handleMessage) + } + + window.addEventListener('message', handleMessage) + + const originPoller = setInterval(() => { + console.log('posting message!', { message, childOrigin }) + win.postMessage(message, childOrigin) + }, 500) + win.focus() return win } else { diff --git a/core/wallet-user-rpc-client/src/index.ts b/core/wallet-user-rpc-client/src/index.ts index f59eb3354..e7313512e 100644 --- a/core/wallet-user-rpc-client/src/index.ts +++ b/core/wallet-user-rpc-client/src/index.ts @@ -181,6 +181,12 @@ export type MessageId = string */ export type Signature = string export type SignedBy = string +/** + * + * The origin (dApp URL) that initiated this transaction request. + * + */ +export type Origin = string /** * * Authentication method configured for this network @@ -350,12 +356,6 @@ export type Status = string * */ export type Message = string -/** - * - * The origin (dApp URL) that initiated this transaction request. - * - */ -export type Origin = string /** * * The timestamp when the API key was created. @@ -526,6 +526,7 @@ export interface ExecuteParams { signedBy: SignedBy } export interface AddSessionParams { + origin: Origin networkId: NetworkId } export interface GetTransactionParams { diff --git a/core/wallet-user-rpc-client/src/openrpc.json b/core/wallet-user-rpc-client/src/openrpc.json index 06da0222f..ca7a5be89 100644 --- a/core/wallet-user-rpc-client/src/openrpc.json +++ b/core/wallet-user-rpc-client/src/openrpc.json @@ -797,13 +797,18 @@ "type": "object", "additionalProperties": false, "properties": { + "origin": { + "title": "origin", + "type": "string", + "description": "origin of the dApp establishing a session" + }, "networkId": { "title": "networkId", "type": "string", "description": "Network Id" } }, - "required": ["networkId"] + "required": ["networkId", "origin"] } } ], diff --git a/wallet-gateway/remote/src/dapp-api/controller.test.ts b/wallet-gateway/remote/src/dapp-api/controller.test.ts index af54ed20b..1a94e720b 100644 --- a/wallet-gateway/remote/src/dapp-api/controller.test.ts +++ b/wallet-gateway/remote/src/dapp-api/controller.test.ts @@ -107,6 +107,7 @@ const auth: AuthContext = { const session: Session = { id: 'session-1', + origin: 'dapp-1', network: 'network1', accessToken: 'session-token', } diff --git a/wallet-gateway/remote/src/dapp-api/controller.ts b/wallet-gateway/remote/src/dapp-api/controller.ts index 5c4d91e4f..8ce9ddc15 100644 --- a/wallet-gateway/remote/src/dapp-api/controller.ts +++ b/wallet-gateway/remote/src/dapp-api/controller.ts @@ -71,7 +71,7 @@ export const dappController = ( return buildController({ connect: async () => { - if (!context || !(await store.getSession())) { + if (!context || !(await store.getSession(context.accessToken))) { return { isConnected: false, isNetworkConnected: false, @@ -128,7 +128,7 @@ export const dappController = ( return null } else { const notifier = notificationService.getNotifier(context.userId) - await store.removeSession() + await store.removeSession(context.accessToken) notifier.emit('statusChanged', { provider: { id: kernelInfo.id, @@ -148,7 +148,7 @@ export const dappController = ( return null }, isConnected: async () => { - if (!context || !(await store.getSession())) { + if (!context || !(await store.getSession(context.accessToken))) { return { isConnected: false, isNetworkConnected: false, @@ -403,7 +403,7 @@ export const dappController = ( url: dappUrl, userUrl: `${userUrl}/login/`, } - if (!context || !(await store.getSession())) { + if (!context || !(await store.getSession(context.accessToken))) { return { provider: provider, connection: { @@ -415,7 +415,7 @@ export const dappController = ( } } - const session = await store.getSession() + const session = await store.getSession(context.accessToken) const network = await store.getCurrentNetwork() const ledgerClient = new LedgerClient({ baseUrl: new URL(network.ledgerApi.baseUrl), diff --git a/wallet-gateway/remote/src/dapp-api/server.ts b/wallet-gateway/remote/src/dapp-api/server.ts index 87a11a00a..8bea4a532 100644 --- a/wallet-gateway/remote/src/dapp-api/server.ts +++ b/wallet-gateway/remote/src/dapp-api/server.ts @@ -47,7 +47,7 @@ export const dapp = ( } const newStore = store.withAuthContext(context) - const session = await newStore.getSession() + const session = await newStore.getSession(context.accessToken) const sessionId = session?.id if (!sessionId) { diff --git a/wallet-gateway/remote/src/ledger/wallet-sync-service.test.ts b/wallet-gateway/remote/src/ledger/wallet-sync-service.test.ts index f4de6658e..38db26dbc 100644 --- a/wallet-gateway/remote/src/ledger/wallet-sync-service.test.ts +++ b/wallet-gateway/remote/src/ledger/wallet-sync-service.test.ts @@ -308,6 +308,7 @@ describe('WalletSyncService - multi-network features', () => { const setSession = async (networkId: string) => { await store.setSession({ id: `sess-${networkId}`, + origin: 'dapp-1', network: networkId, accessToken: 'token', }) diff --git a/wallet-gateway/remote/src/middleware/apiKeyAuth.ts b/wallet-gateway/remote/src/middleware/apiKeyAuth.ts index d5fc8e11f..7c24af894 100644 --- a/wallet-gateway/remote/src/middleware/apiKeyAuth.ts +++ b/wallet-gateway/remote/src/middleware/apiKeyAuth.ts @@ -75,6 +75,7 @@ export function apiKeyAuth( // automatically initiate a session for the API key user await authStore.setSession({ id: v4(), + origin: req.ip || 'unknown', // use the requestor's IP address as the origin for the session network: matchingKey.networkId, accessToken: 'unused', }) diff --git a/wallet-gateway/remote/src/middleware/sessionHandler.ts b/wallet-gateway/remote/src/middleware/sessionHandler.ts index e58aa4474..0a6e40b05 100644 --- a/wallet-gateway/remote/src/middleware/sessionHandler.ts +++ b/wallet-gateway/remote/src/middleware/sessionHandler.ts @@ -38,7 +38,9 @@ export function sessionHandler( next() } else { logger.debug('Checking for active session for ' + context?.userId) - const session = await store.withAuthContext(context).getSession() + const session = await store + .withAuthContext(context) + .getSession(context?.accessToken || '') if (!session) { logger.debug('No active session found for ' + context?.userId) res.status(401).json({ error: 'No active session found' }) diff --git a/wallet-gateway/remote/src/user-api/controller.test.ts b/wallet-gateway/remote/src/user-api/controller.test.ts index 3d02f2b06..84ace0375 100644 --- a/wallet-gateway/remote/src/user-api/controller.test.ts +++ b/wallet-gateway/remote/src/user-api/controller.test.ts @@ -150,6 +150,7 @@ const adminAuth: AuthContext = { const session: Session = { id: 'session-1', + origin: 'dapp-1', network: 'network1', accessToken: 'session-token', } @@ -1038,6 +1039,7 @@ describe('userController', () => { ) const result = await controller.addSession({ + origin: 'dapp-1', networkId: 'network1', }) @@ -1079,7 +1081,10 @@ describe('userController', () => { ) await expect( - controller.addSession({ networkId: 'network1' }) + controller.addSession({ + origin: 'dapp-1', + networkId: 'network1', + }) ).rejects.toThrow('Failed to add session') }) @@ -1098,7 +1103,10 @@ describe('userController', () => { ) await expect( - controller.addSession({ networkId: 'network1' }) + controller.addSession({ + origin: 'dapp-1', + networkId: 'network1', + }) ).resolves.toMatchObject({ network: expect.objectContaining({ id: 'network1' }), status: 'connected', @@ -1121,7 +1129,10 @@ describe('userController', () => { ) await expect( - controller.addSession({ networkId: 'network1' }) + controller.addSession({ + origin: 'dapp-1', + networkId: 'network1', + }) ).rejects.toThrow('Failed to add session') }) @@ -1141,7 +1152,10 @@ describe('userController', () => { ) await expect( - controller.addSession({ networkId: 'network1' }) + controller.addSession({ + origin: 'dapp-1', + networkId: 'network1', + }) ).rejects.toThrow('Failed to add session') }) @@ -1160,7 +1174,10 @@ describe('userController', () => { ) await expect( - controller.addSession({ networkId: 'network1' }) + controller.addSession({ + origin: 'dapp-1', + networkId: 'network1', + }) ).rejects.toThrow('Failed to add session') }) @@ -1179,7 +1196,10 @@ describe('userController', () => { ) await expect( - controller.addSession({ networkId: 'network1' }) + controller.addSession({ + origin: 'dapp-1', + networkId: 'network1', + }) ).rejects.toThrow('Failed to add session') }) @@ -1198,7 +1218,10 @@ describe('userController', () => { ) await expect( - controller.addSession({ networkId: 'network1' }) + controller.addSession({ + origin: 'dapp-1', + networkId: 'network1', + }) ).rejects.toThrow('Failed to add session') }) }) diff --git a/wallet-gateway/remote/src/user-api/controller.ts b/wallet-gateway/remote/src/user-api/controller.ts index ccdb95b4e..a51a8ca4f 100644 --- a/wallet-gateway/remote/src/user-api/controller.ts +++ b/wallet-gateway/remote/src/user-api/controller.ts @@ -724,6 +724,7 @@ export const userController = ( await store.setSession({ id: newSessionId, + origin: params.origin, network: params.networkId, accessToken: connectedContext.accessToken || '', }) @@ -812,9 +813,10 @@ export const userController = ( }, removeSession: async (): Promise => { logger.info({ authContext }, 'Removing session') - const userId = assertConnected(authContext).userId + const { accessToken, userId } = assertConnected(authContext) + const notifier = notificationService.getNotifier(userId) - await store.removeSession() + await store.removeSession(accessToken) notifier.emit('statusChanged', { provider: provider, @@ -833,7 +835,10 @@ export const userController = ( return null }, listSessions: async (): Promise => { - const session = await store.getSession() + // todo: can we call assertConnected here? + const session = await store.getSession( + authContext?.accessToken || '' + ) if (!session) { return { sessions: [] } } diff --git a/wallet-gateway/remote/src/user-api/rpc-gen/typings.ts b/wallet-gateway/remote/src/user-api/rpc-gen/typings.ts index c808e0b3e..5d27072f1 100644 --- a/wallet-gateway/remote/src/user-api/rpc-gen/typings.ts +++ b/wallet-gateway/remote/src/user-api/rpc-gen/typings.ts @@ -180,6 +180,12 @@ export type MessageId = string */ export type Signature = string export type SignedBy = string +/** + * + * The origin (dApp URL) that initiated this transaction request. + * + */ +export type Origin = string /** * * Authentication method configured for this network @@ -349,12 +355,6 @@ export type Status = string * */ export type Message = string -/** - * - * The origin (dApp URL) that initiated this transaction request. - * - */ -export type Origin = string /** * * The timestamp when the API key was created. @@ -525,6 +525,7 @@ export interface ExecuteParams { signedBy: SignedBy } export interface AddSessionParams { + origin: Origin networkId: NetworkId } export interface GetTransactionParams { diff --git a/wallet-gateway/remote/src/web/frontend/index.ts b/wallet-gateway/remote/src/web/frontend/index.ts index 4f71dfcb1..797f55e6c 100644 --- a/wallet-gateway/remote/src/web/frontend/index.ts +++ b/wallet-gateway/remote/src/web/frontend/index.ts @@ -22,6 +22,7 @@ import { toRelHref, toRelPath, } from '@canton-network/core-wallet-ui-components' +import './listeners' const globalPageResetStyle = document.createElement('style') globalPageResetStyle.textContent = ` @@ -287,9 +288,18 @@ export class UserUIAuthRedirect extends LitElement { export const addUserSession = async (token: string, networkId: string) => { const authenticatedUserClient = await createUserClient(token) + const origin = window.sessionStorage.getItem('dappOrigin') + + if (!origin) { + throw new Error( + 'Missing dApp origin in sessionStorage. Cannot add user session.' + ) + } + const session = await authenticatedUserClient.request({ method: 'addSession', params: { + origin, networkId, }, }) diff --git a/wallet-gateway/remote/src/web/frontend/listeners.ts b/wallet-gateway/remote/src/web/frontend/listeners.ts new file mode 100644 index 000000000..109044897 --- /dev/null +++ b/wallet-gateway/remote/src/web/frontend/listeners.ts @@ -0,0 +1,21 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { isSpliceMessageEvent, WalletEvent } from '@canton-network/core-types' + +const handleMessage = (event: MessageEvent) => { + if (!isSpliceMessageEvent(event)) return + if (event.data.type !== WalletEvent.SPLICE_WALLET_BROADCAST_ORIGIN) return + if (event.data.origin !== event.origin) return + + window.sessionStorage.setItem('dappOrigin', event.data.origin) + window.opener.postMessage( + { + type: WalletEvent.SPLICE_WALLET_BROADCAST_ORIGIN_ACK, + }, + event.origin + ) + window.removeEventListener('message', handleMessage) +} + +window.addEventListener('message', handleMessage) diff --git a/yarn.lock b/yarn.lock index 906a8e2ed..55c93f09c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -19062,7 +19062,6 @@ __metadata: resolution: "systeminformation@npm:5.31.17" bin: systeminformation: lib/cli.js - checksum: 10c0/02da446c6125f07c2b22f37668adbbf589c9e29e99c91fd804b41badc22d5f13286d4a3728b7cf2aaf36346545f90a7180ff8fd64ddf9a57a066f3307c52e3ec conditions: (os=darwin | os=linux | os=win32 | os=freebsd | os=openbsd | os=netbsd | os=sunos | os=android) languageName: node linkType: hard