diff --git a/backend/src/federation/authenticateCommunities.ts b/backend/src/federation/authenticateCommunities.ts index 57e0fa57b..6b8796fbf 100644 --- a/backend/src/federation/authenticateCommunities.ts +++ b/backend/src/federation/authenticateCommunities.ts @@ -20,7 +20,7 @@ export async function startCommunityAuthentication( const foreignCom = await DbCommunity.findOneByOrFail({ publicKey: foreignFedCom.publicKey }) if (foreignCom && foreignCom.communityUuid === null && foreignCom.authenticatedAt === null) { try { - const client = AuthenticationClientFactory.getInstance(homeFedCom) + const client = AuthenticationClientFactory.getInstance(foreignFedCom) // eslint-disable-next-line camelcase if (client instanceof V1_0_AuthenticationClient) { const args = new OpenConnectionArgs() diff --git a/backend/src/federation/validateCommunities.ts b/backend/src/federation/validateCommunities.ts index f497be2cb..686465ac7 100644 --- a/backend/src/federation/validateCommunities.ts +++ b/backend/src/federation/validateCommunities.ts @@ -59,7 +59,7 @@ export async function validateCommunities(): Promise { const pubComInfo = await client.getPublicCommunityInfo() if (pubComInfo) { await writeForeignCommunity(dbCom, pubComInfo) - void startCommunityAuthentication(dbCom) + await startCommunityAuthentication(dbCom) logger.debug(`Federation: write publicInfo of community: name=${pubComInfo.name}`) } else { logger.warn('Federation: missing result of getPublicCommunityInfo') diff --git a/federation/package.json b/federation/package.json index 55fb408be..06e1f10fb 100644 --- a/federation/package.json +++ b/federation/package.json @@ -54,6 +54,7 @@ "eslint-plugin-promise": "^6.1.1", "eslint-plugin-security": "^1.7.1", "eslint-plugin-type-graphql": "^1.0.0", + "graphql-tag": "2.12.6", "jest": "^27.2.4", "nodemon": "^2.0.7", "prettier": "^2.3.1", diff --git a/federation/src/client/1_0/AuthenticationClient.ts b/federation/src/client/1_0/AuthenticationClient.ts index 4437e2a69..eb23886bb 100644 --- a/federation/src/client/1_0/AuthenticationClient.ts +++ b/federation/src/client/1_0/AuthenticationClient.ts @@ -7,7 +7,6 @@ import { openConnectionCallback } from './query/openConnectionCallback' import { AuthenticationArgs } from '@/graphql/api/1_0/model/AuthenticationArgs' import { authenticate } from './query/authenticate' - export class AuthenticationClient { dbCom: DbFederatedCommunity endpoint: string @@ -27,12 +26,13 @@ export class AuthenticationClient { }) } - async openConnectionCallback(args: OpenConnectionCallbackArgs): Promise { + async openConnectionCallback(args: OpenConnectionCallbackArgs): Promise { logger.debug('Authentication: openConnectionCallback with endpoint', this.endpoint, args) try { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const { data } = await this.client.rawRequest(openConnectionCallback, { args }) - if (!data?.openConnectionCallback) { + const { data } = await this.client.rawRequest(openConnectionCallback, { args }) + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (data && data.openConnectionCallback) { logger.warn( 'Authentication: openConnectionCallback without response data from endpoint', this.endpoint, @@ -47,24 +47,24 @@ export class AuthenticationClient { } catch (err) { logger.error('Authentication: error on openConnectionCallback', err) } + return false } - async authenticate(args: AuthenticationArgs): Promise { + async authenticate(args: AuthenticationArgs): Promise { logger.debug('Authentication: authenticate with endpoint=', this.endpoint) try { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const { data } = await this.client.rawRequest(authenticate, {}) + const { data } = await this.client.rawRequest(authenticate, { args }) + logger.debug('Authentication: after authenticate: data:', data) // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - if (!data?.authenticate) { - logger.warn( - 'Authentication: authenticate without response data from endpoint', - this.endpoint, - ) - return + const authUuid: string = data?.authenticate.uuid + if (authUuid) { + logger.debug('Authentication: received authenticated uuid', authUuid) + return authUuid } - const } catch (err) { logger.error('Authentication: authenticate failed for endpoint', this.endpoint) } + return null } } diff --git a/federation/src/client/1_0/query/authenticate.ts b/federation/src/client/1_0/query/authenticate.ts index 3079268d9..59eb64646 100644 --- a/federation/src/client/1_0/query/authenticate.ts +++ b/federation/src/client/1_0/query/authenticate.ts @@ -2,6 +2,8 @@ import { gql } from 'graphql-request' export const authenticate = gql` mutation ($args: AuthenticateArgs!) { - authenticate(data: $args) + authenticate(data: $args) { + uuid + } } ` diff --git a/federation/src/graphql/api/1_0/model/AuthenticationArgs.ts b/federation/src/graphql/api/1_0/model/AuthenticationArgs.ts index d0dc200da..5adc476a0 100644 --- a/federation/src/graphql/api/1_0/model/AuthenticationArgs.ts +++ b/federation/src/graphql/api/1_0/model/AuthenticationArgs.ts @@ -1,6 +1,6 @@ -import { ArgsType, Field } from 'type-graphql' +import { Field, InputType } from 'type-graphql' -@ArgsType() +@InputType() export class AuthenticationArgs { @Field(() => String) oneTimeCode: string diff --git a/federation/src/graphql/api/1_0/model/OpenConnectionArgs.ts b/federation/src/graphql/api/1_0/model/OpenConnectionArgs.ts index 9752f4e6f..9afdbca5f 100644 --- a/federation/src/graphql/api/1_0/model/OpenConnectionArgs.ts +++ b/federation/src/graphql/api/1_0/model/OpenConnectionArgs.ts @@ -1,6 +1,6 @@ -import { ArgsType, Field } from 'type-graphql' +import { Field, InputType } from 'type-graphql' -@ArgsType() +@InputType() export class OpenConnectionArgs { @Field(() => String) publicKey: string diff --git a/federation/src/graphql/api/1_0/model/OpenConnectionCallbackArgs.ts b/federation/src/graphql/api/1_0/model/OpenConnectionCallbackArgs.ts index fa4eb17b5..461f6c3d7 100644 --- a/federation/src/graphql/api/1_0/model/OpenConnectionCallbackArgs.ts +++ b/federation/src/graphql/api/1_0/model/OpenConnectionCallbackArgs.ts @@ -1,13 +1,10 @@ -import { ArgsType, Field } from 'type-graphql' +import { Field, InputType } from 'type-graphql' -@ArgsType() +@InputType() export class OpenConnectionCallbackArgs { @Field(() => String) oneTimeCode: string - @Field(() => String) - publicKey: string - @Field(() => String) url: string } diff --git a/federation/src/graphql/api/1_0/resolver/AuthenticationResolver.ts b/federation/src/graphql/api/1_0/resolver/AuthenticationResolver.ts index cd86e87da..d1595cd35 100644 --- a/federation/src/graphql/api/1_0/resolver/AuthenticationResolver.ts +++ b/federation/src/graphql/api/1_0/resolver/AuthenticationResolver.ts @@ -2,14 +2,13 @@ import { Arg, Mutation, Resolver } from 'type-graphql' import { federationLogger as logger } from '@/server/logger' import { Community as DbCommunity } from '@entity/Community' +import { FederatedCommunity as DbFedCommunity } from '@entity/FederatedCommunity' import { LogError } from '@/server/LogError' import { OpenConnectionArgs } from '../model/OpenConnectionArgs' -import { - startOpenConnectionCallback, - startOpenConnectionRedirect, -} from '../util/authenticateCommunity' +import { startAuthentication, startOpenConnectionCallback } from '../util/authenticateCommunity' import { OpenConnectionCallbackArgs } from '../model/OpenConnectionCallbackArgs' -import { ApiVersionType } from '@/client/enum/apiVersionType' +import { CONFIG } from '@/config' +import { AuthenticationArgs } from '../model/AuthenticationArgs' @Resolver() // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -28,7 +27,8 @@ export class AuthenticationResolver { if (!requestedCom) { throw new LogError(`unknown requesting community with publicKey`, args.publicKey) } - void startOpenConnectionRedirect(args, requestedCom, ApiVersionType.V1_0) + // no await to respond immediatly and invoke callback-request asynchron + void startOpenConnectionCallback(args, requestedCom, CONFIG.FEDERATION_API) return true } @@ -38,14 +38,37 @@ export class AuthenticationResolver { args: OpenConnectionCallbackArgs, ): Promise { logger.debug(`Authentication: openConnectionCallback() via apiVersion=1_0 ...`, args) - // first find with args.publicKey the community, which invokes openConnectionCallback - const callbackCom = await DbCommunity.findOneBy({ - publicKey: Buffer.from(args.publicKey), - }) - if (!callbackCom) { - throw new LogError(`unknown callback community with publicKey`, args.publicKey) + // TODO decrypt args.url with homeCom.privateKey and verify signing with callbackFedCom.publicKey + const endPoint = args.url.slice(0, args.url.lastIndexOf('/')) + const apiVersion = args.url.slice(args.url.lastIndexOf('/'), args.url.length) + const callbackFedCom = await DbFedCommunity.findOneBy({ endPoint, apiVersion }) + if (!callbackFedCom) { + throw new LogError(`unknown callback community with url`, args.url) } - void startOpenConnectionCallback(args, callbackCom) + // no await to respond immediatly and invoke authenticate-request asynchron + void startAuthentication(args.oneTimeCode, callbackFedCom) return true } + + @Mutation(() => String) + async authenticate( + @Arg('data') + args: AuthenticationArgs, + ): Promise { + logger.debug(`Authentication: authenticate() via apiVersion=1_0 ...`, args) + const authCom = await DbCommunity.findOneByOrFail({ communityUuid: args.oneTimeCode }) + logger.debug('Authentication: found authCom:', authCom) + if (authCom) { + // TODO decrypt args.uuid with authCom.publicKey + authCom.communityUuid = args.uuid + await DbCommunity.save(authCom) + logger.debug('Authentication: store authCom.uuid successfully:', authCom) + const homeCom = await DbCommunity.findOneByOrFail({ foreign: false }) + // TODO encrypt homeCom.uuid with homeCom.privateKey + if (homeCom.communityUuid) { + return homeCom.communityUuid + } + } + return null + } } diff --git a/federation/src/graphql/api/1_0/util/authenticateCommunity.ts b/federation/src/graphql/api/1_0/util/authenticateCommunity.ts index b2a4a832c..65e9c3e6f 100644 --- a/federation/src/graphql/api/1_0/util/authenticateCommunity.ts +++ b/federation/src/graphql/api/1_0/util/authenticateCommunity.ts @@ -1,19 +1,19 @@ import { OpenConnectionArgs } from '../model/OpenConnectionArgs' import { Community as DbCommunity } from '@entity/Community' -import { FederatedCommunity as DbFederatedCommunity } from '@entity/FederatedCommunity' +import { FederatedCommunity as DbFedCommunity } from '@entity/FederatedCommunity' import { federationLogger as logger } from '@/server/logger' import { OpenConnectionCallbackArgs } from '../model/OpenConnectionCallbackArgs' // eslint-disable-next-line camelcase import { randombytes_random } from 'sodium-native' import { AuthenticationClientFactory } from '@/client/AuthenticationClientFactory' -import { ApiVersionType } from '@/client/enum/apiVersionType' // eslint-disable-next-line camelcase import { AuthenticationClient as V1_0_AuthenticationClient } from '@/client/1_0/AuthenticationClient' +import { AuthenticationArgs } from '../model/AuthenticationArgs' -export async function startOpenConnectionRedirect( +export async function startOpenConnectionCallback( args: OpenConnectionArgs, requestedCom: DbCommunity, - api: ApiVersionType, + api: string, ): Promise { logger.debug( `Authentication: startOpenConnectionRedirect()...`, @@ -22,14 +22,13 @@ export async function startOpenConnectionRedirect( requestedCom, ) try { - // TODO verify signing of args.url with requestedCom.publicKey and decrypt with homeCom.privateKey const homeCom = await DbCommunity.findOneByOrFail({ foreign: false }) - const homeFedCom = await DbFederatedCommunity.findOneByOrFail({ + const homeFedCom = await DbFedCommunity.findOneByOrFail({ foreign: false, apiVersion: api, }) const oneTimeCode = randombytes_random() - // store oneTimeCode in requestedCom.community_uuid for authenticate-request-identifier + // store oneTimeCode in requestedCom.community_uuid as authenticate-request-identifier requestedCom.communityUuid = oneTimeCode.toString() await DbCommunity.save(requestedCom) @@ -38,8 +37,7 @@ export async function startOpenConnectionRedirect( if (client instanceof V1_0_AuthenticationClient) { const callbackArgs = new OpenConnectionCallbackArgs() callbackArgs.oneTimeCode = oneTimeCode.toString() - callbackArgs.publicKey = homeCom.publicKey.toString('hex') - // TODO signing of callbackArgs.url with requestedCom.publicKey and decrypt with homeCom.privateKey + // TODO encrypt callbackArgs.url with requestedCom.publicKey and sign it with homeCom.privateKey callbackArgs.url = homeFedCom.endPoint.endsWith('/') ? homeFedCom.endPoint : homeFedCom.endPoint + '/' + homeFedCom.apiVersion @@ -54,21 +52,45 @@ export async function startOpenConnectionRedirect( } } -export async function startOpenConnectionCallback( - args: OpenConnectionCallbackArgs, - callbackCom: DbCommunity, +export async function startAuthentication( + oneTimeCode: string, + callbackFedCom: DbFedCommunity, ): Promise { - logger.debug( - `Authentication: startOpenConnectionCallback()...`, - args.publicKey, - args.url, - callbackCom, - ) + logger.debug(`Authentication: startAuthentication()...`, oneTimeCode, callbackFedCom) try { - // TODO verify signing of args.url with requestedCom.publicKey and decrypt with homeCom.privateKey const homeCom = await DbCommunity.findOneByOrFail({ foreign: false }) - - + const homeFedCom = await DbFedCommunity.findOneByOrFail({ + foreign: false, + apiVersion: callbackFedCom.apiVersion, + }) + + // TODO encrypt homeCom.uuid with homeCom.privateKey and sign it with callbackFedCom.publicKey + const client = AuthenticationClientFactory.getInstance(homeFedCom) + // eslint-disable-next-line camelcase + if (client instanceof V1_0_AuthenticationClient) { + const authenticationArgs = new AuthenticationArgs() + authenticationArgs.oneTimeCode = oneTimeCode + // TODO encrypt callbackArgs.url with requestedCom.publicKey and sign it with homeCom.privateKey + if (homeCom.communityUuid) { + authenticationArgs.uuid = homeCom.communityUuid + } + logger.debug(`Authentication: vor authenticate()...`, authenticationArgs) + const fedComUuid = await client.authenticate(authenticationArgs) + logger.debug(`Authentication: nach authenticate()...`, fedComUuid) + if (fedComUuid !== null) { + // TODO decrypt fedComUuid with callbackFedCom.publicKey + const callbackCom = await DbCommunity.findOneByOrFail({ + foreign: true, + publicKey: callbackFedCom.publicKey, + }) + callbackCom.communityUuid = fedComUuid + callbackCom.authenticatedAt = new Date() + await DbCommunity.save(callbackCom) + logger.debug('Authentication: Community Authentication successful:', callbackCom) + } else { + logger.error('Authentication: Community Authentication failed:', authenticationArgs) + } + } } catch (err) { logger.error('Authentication: error in startOpenConnectionCallback:', err) } diff --git a/federation/src/graphql/schema.ts b/federation/src/graphql/schema.ts index 0951c1000..d1be63b00 100644 --- a/federation/src/graphql/schema.ts +++ b/federation/src/graphql/schema.ts @@ -11,6 +11,14 @@ const schema = async (): Promise => { resolvers: [getApiResolvers()], // authChecker: isAuthorized, scalarsMap: [{ type: Decimal, scalar: DecimalScalar }], + validate: { + validationError: { target: false }, + skipMissingProperties: true, + skipNullProperties: true, + skipUndefinedProperties: false, + forbidUnknownValues: true, + stopAtFirstError: true, + }, }) } diff --git a/federation/test/extensions.ts b/federation/test/extensions.ts deleted file mode 100644 index 262a9bcdb..000000000 --- a/federation/test/extensions.ts +++ /dev/null @@ -1,37 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unsafe-call */ -/* eslint-disable @typescript-eslint/no-unsafe-member-access */ -/* eslint-disable @typescript-eslint/restrict-template-expressions */ -/* eslint-disable @typescript-eslint/no-empty-interface */ -/* eslint-disable @typescript-eslint/no-unsafe-argument */ - -import { Decimal } from 'decimal.js-light' - -expect.extend({ - decimalEqual(received, value) { - const pass = new Decimal(value).equals(received.toString()) - if (pass) { - return { - message: () => `expected ${received} to not equal ${value}`, - pass: true, - } - } else { - return { - message: () => `expected ${received} to equal ${value}`, - pass: false, - } - } - }, -}) - -interface CustomMatchers { - decimalEqual(value: number): R -} - -declare global { - // eslint-disable-next-line @typescript-eslint/no-namespace - namespace jest { - interface Expect extends CustomMatchers {} - interface Matchers extends CustomMatchers {} - interface InverseAsymmetricMatchers extends CustomMatchers {} - } -} diff --git a/federation/test/helpers.test.ts b/federation/test/helpers.test.ts deleted file mode 100644 index 69d8f3fa4..000000000 --- a/federation/test/helpers.test.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { contributionDateFormatter } from '@test/helpers' - -describe('contributionDateFormatter', () => { - it('formats the date correctly', () => { - expect(contributionDateFormatter(new Date('Thu Feb 29 2024 13:12:11'))).toEqual('2/29/2024') - }) -}) diff --git a/federation/test/helpers.ts b/federation/test/helpers.ts deleted file mode 100644 index 3b05edf4d..000000000 --- a/federation/test/helpers.ts +++ /dev/null @@ -1,62 +0,0 @@ -/* eslint-disable @typescript-eslint/unbound-method */ -/* eslint-disable @typescript-eslint/no-unsafe-assignment */ -/* eslint-disable @typescript-eslint/no-unsafe-member-access */ -/* eslint-disable @typescript-eslint/no-explicit-any */ -/* eslint-disable @typescript-eslint/no-unsafe-call */ -/* eslint-disable @typescript-eslint/no-unsafe-return */ -import { entities } from '@entity/index' -import { createTestClient } from 'apollo-server-testing' - -import { createServer } from '@/server/createServer' - -import { logger } from './testSetup' - -export const headerPushMock = jest.fn((t) => { - context.token = t.value -}) - -const context = { - token: '', - setHeaders: { - push: headerPushMock, - forEach: jest.fn(), - }, - clientTimezoneOffset: 0, -} - -export const cleanDB = async () => { - // this only works as long we do not have foreign key constraints - for (const entity of entities) { - await resetEntity(entity) - } -} - -export const testEnvironment = async (testLogger = logger) => { - const server = await createServer(testLogger) // context, testLogger, testI18n) - const con = server.con - const testClient = createTestClient(server.apollo) - const mutate = testClient.mutate - const query = testClient.query - return { mutate, query, con } -} - -export const resetEntity = async (entity: any) => { - const items = await entity.find({ withDeleted: true }) - if (items.length > 0) { - const ids = items.map((e: any) => e.id) - await entity.delete(ids) - } -} - -export const resetToken = () => { - context.token = '' -} - -// format date string as it comes from the frontend for the contribution date -export const contributionDateFormatter = (date: Date): string => { - return `${date.getMonth() + 1}/${date.getDate()}/${date.getFullYear()}` -} - -export const setClientTimezoneOffset = (offset: number): void => { - context.clientTimezoneOffset = offset -} diff --git a/federation/test/testSetup.ts b/federation/test/testSetup.ts deleted file mode 100644 index 4341a1b49..000000000 --- a/federation/test/testSetup.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { federationLogger as logger } from '@/server/logger' - -jest.setTimeout(1000000) - -jest.mock('@/server/logger', () => { - const originalModule = jest.requireActual('@/server/logger') - return { - __esModule: true, - ...originalModule, - backendLogger: { - addContext: jest.fn(), - trace: jest.fn(), - debug: jest.fn(), - warn: jest.fn(), - info: jest.fn(), - error: jest.fn(), - fatal: jest.fn(), - }, - } -}) - -export { logger } diff --git a/federation/yarn.lock b/federation/yarn.lock index 1f3cdca2c..43acc0e8a 100644 --- a/federation/yarn.lock +++ b/federation/yarn.lock @@ -3058,7 +3058,7 @@ graphql-subscriptions@^1.0.0, graphql-subscriptions@^1.1.0: dependencies: iterall "^1.3.0" -graphql-tag@^2.11.0: +graphql-tag@2.12.6, graphql-tag@^2.11.0: version "2.12.6" resolved "https://registry.yarnpkg.com/graphql-tag/-/graphql-tag-2.12.6.tgz#d441a569c1d2537ef10ca3d1633b48725329b5f1" integrity sha512-FdSNcu2QQcWnM2VNvSCCDCVS5PpPqpzgFT8+GXzqJuoDd0CBncxCY278u4mhRO7tMgo2JjgJA5aZ+nWSQ/Z+xg==