Merge branch 'master' into refactor_dlt_connector_modern_stack

This commit is contained in:
einhornimmond 2025-07-18 08:10:47 +02:00 committed by GitHub
commit 491a13894f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
97 changed files with 4163 additions and 3868 deletions

View File

@ -24,7 +24,7 @@ jobs:
- name: Prepare test system
run: |
sudo chown runner:docker -R *
bun install
bun install --frozen-lockfile
sudo cp ./nginx/e2e-test.conf /etc/nginx/sites-available/default
- name: Boot up test system | seed backend
@ -40,9 +40,8 @@ jobs:
cd backend
cp .env.test_e2e .env
cd ..
bun turbo backend#build
bun turbo frontend#build
bun turbo backend#start frontend#start --env-mode=loose &
bun turbo backend#build frontend#build --env-mode=loose
bun turbo backend#start frontend#start --env-mode=loose &
- name: End-to-end tests | prepare
run: |
@ -106,3 +105,154 @@ jobs:
with:
name: backend-logs-pr-#${{ steps.pr.outputs.number }}
path: /home/runner/work/gradido/gradido/logs/backend
end-to-end-tests-playwright:
name: End-to-End Tests Playwright
runs-on: ubuntu-22.04
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set Node.js version
uses: actions/setup-node@v4
with:
node-version: '18.20.7'
- name: install bun
uses: oven-sh/setup-bun@v2
- name: Boot up test system | docker-compose mariadb mailserver
run: docker compose -f docker-compose.yml -f docker-compose.test.yml up --detach mariadb mailserver
- name: Prepare test system
run: |
sudo chown runner:docker -R *
bun install --frozen-lockfile
sudo cp ./nginx/e2e-test.conf /etc/nginx/sites-available/default
- name: Boot up test system | seed backend
run: bun turbo seed
- name: copy test config
run: |
cd frontend
cp .env.test_e2e .env
cd ../backend
cp .env.test_e2e .env
- name: Moving logs after seeding
run: |
mkdir -p /home/runner/work/gradido/gradido/logs/backend/seed
mv /home/runner/work/gradido/gradido/logs/backend/*.log /home/runner/work/gradido/gradido/logs/backend/seed/
- name: Boot up test system | docker-compose backend, frontend
run: |
bun turbo backend#build frontend#build --env-mode=loose
bun turbo backend#start frontend#start --env-mode=loose &
- name: End-to-end tests | prepare
run: |
cd e2e-tests/playwright/typescript
bun install
- name: wait for frontend and backend to be ready
run: |
until nc -z 127.0.0.1 3000; do echo waiting for frontend; sleep 1; done;
until nc -z 127.0.0.1 4000; do echo waiting for backend; sleep 1; done;
- name: Start local nginx webserver
run: |
sudo nginx -t
sudo systemctl start nginx
- name: wait for nginx and mailserver to be ready
run: |
until nc -z 127.0.0.1 80; do echo waiting for nginx; sleep 1; done;
until nc -z 127.0.0.1 1025; do echo waiting for mailserver; sleep 1; done;
- name: End-to-end tests | run tests
id: e2e-tests
run: |
cd e2e-tests/playwright/typescript
bun run test --project="Google Chrome"
- name: End-to-end tests | if tests failed, upload video
id: e2e-video
if: ${{ failure() && steps.e2e-tests.conclusion == 'failure' }}
uses: actions/upload-artifact@v4
with:
name: cypress-videos-pr-#${{ steps.pr.outputs.number }}
path: /home/runner/work/gradido/gradido/e2e-tests/playwright/typescript/test-results
end-to-end-tests-playwright-java:
name: End-to-End Tests Playwright java
runs-on: ubuntu-22.04
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set Node.js version
uses: actions/setup-node@v4
with:
node-version: '18.20.7'
- uses: actions/setup-java@v3
with:
distribution: 'temurin'
java-version: '17'
- name: install bun
uses: oven-sh/setup-bun@v2
- name: Boot up test system | docker-compose mariadb mailserver
run: docker compose -f docker-compose.yml -f docker-compose.test.yml up --detach mariadb mailserver
- name: Prepare test system
run: |
sudo chown runner:docker -R *
bun install
sudo cp ./nginx/e2e-test.conf /etc/nginx/sites-available/default
- name: Boot up test system | seed backend
run: bun turbo seed
- name: Moving logs after seeding
run: |
mkdir -p /home/runner/work/gradido/gradido/logs/backend/seed
mv /home/runner/work/gradido/gradido/logs/backend/*.log /home/runner/work/gradido/gradido/logs/backend/seed/
- name: Boot up test system | docker-compose backend, frontend
run: |
cd backend
cp .env.test_e2e .env
cd ..
bun turbo backend#build frontend#build --env-mode=loose
bun turbo backend#start frontend#start --env-mode=loose &
- name: End-to-end tests | prepare
run: |
cd e2e-tests/playwright/typescript
bun install
- name: wait for frontend and backend to be ready
run: |
until nc -z 127.0.0.1 3000; do echo waiting for frontend; sleep 1; done;
until nc -z 127.0.0.1 4000; do echo waiting for backend; sleep 1; done;
- name: Start local nginx webserver
run: |
sudo nginx -t
sudo systemctl start nginx
- name: wait for nginx and mailserver to be ready
run: |
until nc -z 127.0.0.1 80; do echo waiting for nginx; sleep 1; done;
until nc -z 127.0.0.1 1025; do echo waiting for mailserver; sleep 1; done;
- name: End-to-end tests | run tests
id: e2e-tests
run: |
cd e2e-tests/playwright/java
mvn -B install -D skipTests --no-transfer-progress
mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI -D exec.args="install --with-deps"
mvn test

View File

@ -174,6 +174,13 @@ turbo frontend#dev backend#start admin#start --env-mode=loose
Tip: for local setup use a local nginx server with similar config like docker nginx [nginx.conf](./nginx/gradido.conf) but replace docker image name with localhost
### Clear
In root folder calling `yarn clear` will clear all turbo caches, node_modules and build folders of all workspaces for a clean rebuild.
```bash
yarn clear
```
## Services defined in this package

View File

@ -19,7 +19,8 @@
"test:debug": "cross-env TZ=UTC node --inspect-brk ./node_modules/vitest/vitest.mjs",
"test:watch": "cross-env TZ=UTC vitest",
"locales": "scripts/sort.sh",
"locales:fix": "scripts/sort.sh --fix"
"locales:fix": "scripts/sort.sh --fix",
"clear": "rm -rf node_modules && rm -rf build && rm -rf .turbo"
},
"dependencies": {
"@iconify/json": "^2.2.228",

View File

@ -4,6 +4,7 @@ JWT_EXPIRES_IN=2m
GDT_ACTIVE=false
HUMHUB_ACTIVE=false
GMS_ACTIVE=false
USE_CRYPTO_WORKER=true
# Email
EMAIL=true

View File

@ -22,7 +22,8 @@
"locales": "scripts/sort.sh",
"locales:fix": "scripts/sort.sh --fix",
"start": "cross-env TZ=UTC node build/index.js",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"clear": "rm -rf node_modules && rm -rf build && rm -rf .turbo"
},
"nodemonConfig": {
"ignore": [

View File

@ -1,72 +0,0 @@
import { SignJWT, decodeJwt, jwtVerify } from 'jose'
import { LogError } from '@/server/LogError'
import { JwtPayloadType } from './payloadtypes/JwtPayloadType'
import { LOG4JS_BASE_CATEGORY_NAME } from '@/config/const'
import { getLogger } from 'log4js'
const logger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.auth.jwt.JWT`)
export const verify = async (token: string, signkey: string): Promise<JwtPayloadType | null> => {
if (!token) {
throw new LogError('401 Unauthorized')
}
logger.info('JWT.verify... token, signkey=', token, signkey)
try {
/*
const { KeyObject } = await import('node:crypto')
const cryptoKey = await crypto.subtle.importKey('raw', signkey, { name: 'RS256' }, false, [
'sign',
])
const keyObject = KeyObject.from(cryptoKey)
logger.info('JWT.verify... keyObject=', keyObject)
logger.info('JWT.verify... keyObject.asymmetricKeyDetails=', keyObject.asymmetricKeyDetails)
logger.info('JWT.verify... keyObject.asymmetricKeyType=', keyObject.asymmetricKeyType)
logger.info('JWT.verify... keyObject.asymmetricKeySize=', keyObject.asymmetricKeySize)
*/
const secret = new TextEncoder().encode(signkey)
const { payload } = await jwtVerify(token, secret, {
issuer: 'urn:gradido:issuer',
audience: 'urn:gradido:audience',
})
logger.info('JWT.verify after jwtVerify... payload=', payload)
return payload as JwtPayloadType
} catch (err) {
logger.error('JWT.verify after jwtVerify... error=', err)
return null
}
}
export const encode = async (payload: JwtPayloadType, signkey: string): Promise<string> => {
logger.info('JWT.encode... payload=', payload)
logger.info('JWT.encode... signkey=', signkey)
try {
const secret = new TextEncoder().encode(signkey)
const token = await new SignJWT({ payload, 'urn:gradido:claim': true })
.setProtectedHeader({
alg: 'HS256',
})
.setIssuedAt()
.setIssuer('urn:gradido:issuer')
.setAudience('urn:gradido:audience')
.setExpirationTime(payload.expiration)
.sign(secret)
return token
} catch (e) {
logger.error('Failed to sign JWT:', e)
throw e
}
}
export const verifyJwtType = async (token: string, signkey: string): Promise<string> => {
const payload = await verify(token, signkey)
return payload ? payload.tokentype : 'unknown token type'
}
export const decode = (token: string): JwtPayloadType => {
const { payload } = decodeJwt(token)
return payload as JwtPayloadType
}

View File

@ -42,7 +42,7 @@ export const schema = Joi.object({
OPENAI_ACTIVE,
PRODUCTION,
COMMUNITY_REDEEM_URL: Joi.string()
COMMUNITY_REDEEM_URL: Joi.string()
.uri({ scheme: ['http', 'https'] })
.description('The url for redeeming link transactions, must start with frontend base url')
.default('http://0.0.0.0/redeem/')

View File

@ -1,61 +1,78 @@
import { Community as DbCommunity, FederatedCommunity as DbFederatedCommunity } from 'database'
import { CommunityLoggingView, Community as DbCommunity, FederatedCommunity as DbFederatedCommunity, FederatedCommunityLoggingView, getHomeCommunity } from 'database'
import { validate as validateUUID, version as versionUUID } from 'uuid'
import { randombytes_random } from 'sodium-native'
import { CONFIG } from '@/config'
import { AuthenticationClient as V1_0_AuthenticationClient } from '@/federation/client/1_0/AuthenticationClient'
import { ensureUrlEndsWithSlash } from '@/util/utilities'
import { getLogger } from 'log4js'
import { OpenConnectionArgs } from './client/1_0/model/OpenConnectionArgs'
import { AuthenticationClientFactory } from './client/AuthenticationClientFactory'
import { LOG4JS_BASE_CATEGORY_NAME } from '@/config/const'
import { encryptAndSign, OpenConnectionJwtPayloadType } from 'shared'
import { getLogger } from 'log4js'
import { AuthenticationClientFactory } from './client/AuthenticationClientFactory'
import { EncryptedTransferArgs } from 'core'
const logger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.federation.authenticateCommunities`)
export async function startCommunityAuthentication(
foreignFedCom: DbFederatedCommunity,
fedComB: DbFederatedCommunity,
): Promise<void> {
const homeCom = await DbCommunity.findOneByOrFail({ foreign: false })
const homeFedCom = await DbFederatedCommunity.findOneByOrFail({
const methodLogger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.federation.authenticateCommunities.startCommunityAuthentication`)
const handshakeID = randombytes_random().toString()
methodLogger.addContext('handshakeID', handshakeID)
methodLogger.debug(`startCommunityAuthentication()...`, {
fedComB: new FederatedCommunityLoggingView(fedComB),
})
const homeComA = await getHomeCommunity()
methodLogger.debug('homeComA', new CommunityLoggingView(homeComA!))
const homeFedComA = await DbFederatedCommunity.findOneByOrFail({
foreign: false,
apiVersion: CONFIG.FEDERATION_BACKEND_SEND_ON_API,
})
const foreignCom = await DbCommunity.findOneByOrFail({ publicKey: foreignFedCom.publicKey })
logger.debug(
'Authentication: started with foreignFedCom:',
foreignFedCom.endPoint,
foreignFedCom.publicKey.toString('hex'),
)
// check if communityUuid is a valid v4Uuid and not still a temporary onetimecode
if (
foreignCom &&
((foreignCom.communityUuid === null && foreignCom.authenticatedAt === null) ||
(foreignCom.communityUuid !== null &&
!validateUUID(foreignCom.communityUuid) &&
versionUUID(foreignCom.communityUuid) !== 4))
) {
try {
const client = AuthenticationClientFactory.getInstance(foreignFedCom)
methodLogger.debug('homeFedComA', new FederatedCommunityLoggingView(homeFedComA))
const comB = await DbCommunity.findOneByOrFail({ publicKey: fedComB.publicKey })
methodLogger.debug('started with comB:', new CommunityLoggingView(comB))
// check if communityUuid is not a valid v4Uuid
try {
if (
comB &&
((comB.communityUuid === null && comB.authenticatedAt === null) ||
(comB.communityUuid !== null &&
(!validateUUID(comB.communityUuid) ||
versionUUID(comB.communityUuid!) !== 4)))
) {
methodLogger.debug('comB.uuid is null or is a not valid v4Uuid...', comB.communityUuid || 'null', comB.authenticatedAt || 'null')
const client = AuthenticationClientFactory.getInstance(fedComB)
if (client instanceof V1_0_AuthenticationClient) {
const args = new OpenConnectionArgs()
args.publicKey = homeCom.publicKey.toString('hex')
// TODO encrypt url with foreignCom.publicKey and sign it with homeCom.privateKey
args.url = ensureUrlEndsWithSlash(homeFedCom.endPoint).concat(homeFedCom.apiVersion)
logger.debug(
'Authentication: before client.openConnection() args:',
homeCom.publicKey.toString('hex'),
args.url,
if (!comB.publicJwtKey) {
throw new Error('Public JWT key still not exist for comB ' + comB.name)
}
//create JWT with url in payload encrypted by foreignCom.publicJwtKey and signed with homeCom.privateJwtKey
const payload = new OpenConnectionJwtPayloadType(handshakeID,
ensureUrlEndsWithSlash(homeFedComA.endPoint).concat(homeFedComA.apiVersion),
)
if (await client.openConnection(args)) {
logger.debug(`Authentication: successful initiated at community:`, foreignFedCom.endPoint)
methodLogger.debug('payload', payload)
const jws = await encryptAndSign(payload, homeComA!.privateJwtKey!, comB.publicJwtKey!)
methodLogger.debug('jws', jws)
// prepare the args for the client invocation
const args = new EncryptedTransferArgs()
args.publicKey = homeComA!.publicKey.toString('hex')
args.jwt = jws
args.handshakeID = handshakeID
methodLogger.debug('before client.openConnection() args:', args)
const result = await client.openConnection(args)
if (result) {
methodLogger.debug(`successful initiated at community:`, fedComB.endPoint)
} else {
logger.error(`Authentication: can't initiate at community:`, foreignFedCom.endPoint)
methodLogger.error(`can't initiate at community:`, fedComB.endPoint)
}
}
} catch (err) {
logger.error(`Error:`, err)
} else {
methodLogger.debug(`comB.communityUuid is already a valid v4Uuid ${ comB.communityUuid || 'null' } and was authenticated at ${ comB.authenticatedAt || 'null'}`)
}
} catch (err) {
methodLogger.error(`Error:`, err)
}
methodLogger.removeContext('handshakeID')
}

View File

@ -5,7 +5,7 @@ import { ensureUrlEndsWithSlash } from '@/util/utilities'
import { LOG4JS_BASE_CATEGORY_NAME } from '@/config/const'
import { getLogger } from 'log4js'
import { OpenConnectionArgs } from './model/OpenConnectionArgs'
import { EncryptedTransferArgs } from 'core/src/graphql/model/EncryptedTransferArgs'
import { openConnection } from './query/openConnection'
const logger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.federation.client.1_0.AuthenticationClient`)
@ -27,7 +27,7 @@ export class AuthenticationClient {
})
}
async openConnection(args: OpenConnectionArgs): Promise<boolean | undefined> {
async openConnection(args: EncryptedTransferArgs): Promise<boolean | undefined> {
logger.debug(`openConnection at ${this.endpoint} for args:`, args)
try {
const { data } = await this.client.rawRequest<{ openConnection: boolean }>(openConnection, {

View File

@ -13,6 +13,7 @@ export class PublicCommunityInfoLoggingView extends AbstractLoggingView {
description: this.self.description,
creationDate: this.dateToString(this.self.creationDate),
publicKey: this.self.publicKey,
publicJwtKey: this.self.publicJwtKey,
}
}
}

View File

@ -6,5 +6,5 @@ export class OpenConnectionArgs {
publicKey: string
@Field(() => String)
url: string
jwt: string
}

View File

@ -3,4 +3,5 @@ export interface PublicCommunityInfo {
description: string
creationDate: Date
publicKey: string
publicJwtKey: string
}

View File

@ -7,6 +7,7 @@ export const getPublicCommunityInfo = gql`
description
creationDate
publicKey
publicJwtKey
}
}
`

View File

@ -1,7 +1,7 @@
import { gql } from 'graphql-request'
export const openConnection = gql`
mutation ($args: OpenConnectionArgs!) {
mutation ($args: EncryptedTransferArgs!) {
openConnection(data: $args)
}
`

View File

@ -343,7 +343,7 @@ describe('validate Communities', () => {
})
it('logs unsupported api for community with api 2_0 ', () => {
expect(logger.debug).toBeCalledWith(
'dbCom with unsupported apiVersion',
'dbFedComB with unsupported apiVersion',
dbCom.endPoint,
'2_0',
)

View File

@ -2,18 +2,20 @@ import {
Community as DbCommunity,
FederatedCommunity as DbFederatedCommunity,
FederatedCommunityLoggingView,
getHomeCommunity,
} from 'database'
import { IsNull } from 'typeorm'
import { LOG4JS_BASE_CATEGORY_NAME } from '@/config/const'
import { FederationClient as V1_0_FederationClient } from '@/federation/client/1_0/FederationClient'
import { PublicCommunityInfo } from '@/federation/client/1_0/model/PublicCommunityInfo'
import { FederationClientFactory } from '@/federation/client/FederationClientFactory'
import { LogError } from '@/server/LogError'
import { createKeyPair } from 'shared'
import { getLogger } from 'log4js'
import { startCommunityAuthentication } from './authenticateCommunities'
import { PublicCommunityInfoLoggingView } from './client/1_0/logging/PublicCommunityInfoLogging.view'
import { ApiVersionType } from './enum/apiVersionType'
import { LOG4JS_BASE_CATEGORY_NAME } from '@/config/const'
const logger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.federation.validateCommunities`)
@ -34,6 +36,7 @@ export async function startValidateCommunities(timerInterval: number): Promise<v
}
export async function validateCommunities(): Promise<void> {
// search all foreign federated communities which are still not verified or have not been verified since last dht-announcement
const dbFederatedCommunities: DbFederatedCommunity[] =
await DbFederatedCommunity.createQueryBuilder()
.where({ foreign: true, verifiedAt: IsNull() })
@ -41,32 +44,36 @@ export async function validateCommunities(): Promise<void> {
.getMany()
logger.debug(`found ${dbFederatedCommunities.length} dbCommunities`)
for (const dbCom of dbFederatedCommunities) {
logger.debug('dbCom', new FederatedCommunityLoggingView(dbCom))
for (const dbFedComB of dbFederatedCommunities) {
logger.debug('dbFedComB', new FederatedCommunityLoggingView(dbFedComB))
const apiValueStrings: string[] = Object.values(ApiVersionType)
logger.debug(`suppported ApiVersions=`, apiValueStrings)
if (!apiValueStrings.includes(dbCom.apiVersion)) {
logger.debug('dbCom with unsupported apiVersion', dbCom.endPoint, dbCom.apiVersion)
if (!apiValueStrings.includes(dbFedComB.apiVersion)) {
logger.debug('dbFedComB with unsupported apiVersion', dbFedComB.endPoint, dbFedComB.apiVersion)
continue
}
try {
const client = FederationClientFactory.getInstance(dbCom)
const client = FederationClientFactory.getInstance(dbFedComB)
if (client instanceof V1_0_FederationClient) {
const pubKey = await client.getPublicKey()
if (pubKey && pubKey === dbCom.publicKey.toString('hex')) {
await DbFederatedCommunity.update({ id: dbCom.id }, { verifiedAt: new Date() })
logger.debug(`verified community with:`, dbCom.endPoint)
if (pubKey && pubKey === dbFedComB.publicKey.toString('hex')) {
await DbFederatedCommunity.update({ id: dbFedComB.id }, { verifiedAt: new Date() })
logger.debug(`verified dbFedComB with:`, dbFedComB.endPoint)
const pubComInfo = await client.getPublicCommunityInfo()
if (pubComInfo) {
await writeForeignCommunity(dbCom, pubComInfo)
await startCommunityAuthentication(dbCom)
logger.debug(`write publicInfo of community: name=${pubComInfo.name}`)
await writeForeignCommunity(dbFedComB, pubComInfo)
logger.debug(`wrote response of getPublicCommunityInfo in dbFedComB ${dbFedComB.endPoint}`)
try {
await startCommunityAuthentication(dbFedComB)
} catch (err) {
logger.warn(`Warning: Authentication of community ${dbFedComB.endPoint} still ongoing:`, err)
}
} else {
logger.debug('missing result of getPublicCommunityInfo')
}
} else {
logger.debug('received not matching publicKey:', pubKey, dbCom.publicKey.toString('hex'))
logger.debug('received not matching publicKey:', pubKey, dbFedComB.publicKey.toString('hex'))
}
}
} catch (err) {
@ -75,6 +82,36 @@ export async function validateCommunities(): Promise<void> {
}
}
export async function writeJwtKeyPairInHomeCommunity(): Promise<DbCommunity> {
logger.debug(`Federation: writeJwtKeyPairInHomeCommunity`)
try {
// check for existing homeCommunity entry
let homeCom = await getHomeCommunity()
if (homeCom) {
if (!homeCom.publicJwtKey && !homeCom.privateJwtKey) {
// Generate key pair using jose library
const { publicKey, privateKey } = await createKeyPair();
logger.debug(`Federation: writeJwtKeyPairInHomeCommunity publicKey=`, publicKey);
logger.debug(`Federation: writeJwtKeyPairInHomeCommunity privateKey=`, privateKey.slice(0, 20));
homeCom.publicJwtKey = publicKey;
logger.debug(`Federation: writeJwtKeyPairInHomeCommunity publicJwtKey.length=`, homeCom.publicJwtKey.length);
homeCom.privateJwtKey = privateKey;
logger.debug(`Federation: writeJwtKeyPairInHomeCommunity privateJwtKey.length=`, homeCom.privateJwtKey.length);
await DbCommunity.save(homeCom)
logger.debug(`Federation: writeJwtKeyPairInHomeCommunity done`)
} else {
logger.debug(`Federation: writeJwtKeyPairInHomeCommunity: keypair already exists`)
}
} else {
throw new Error(`Error! A HomeCommunity-Entry still not exist! Please start the DHT-Modul first.`)
}
return homeCom
} catch (err) {
throw new Error(`Error writing JwtKeyPair in HomeCommunity-Entry: ${err}`)
}
}
async function writeForeignCommunity(
dbCom: DbFederatedCommunity,
pubInfo: PublicCommunityInfo,
@ -96,6 +133,7 @@ async function writeForeignCommunity(
com.foreign = true
com.name = pubInfo.name
com.publicKey = dbCom.publicKey
com.publicJwtKey = pubInfo.publicJwtKey
com.url = dbCom.endPoint
await DbCommunity.save(com)
}

View File

@ -1,7 +1,7 @@
import { Decimal } from 'decimal.js-light'
import { Field, ObjectType } from 'type-graphql'
import { RedeemJwtPayloadType } from '@/auth/jwt/payloadtypes/RedeemJwtPayloadType'
import { RedeemJwtPayloadType } from 'shared'
import { Community } from './Community'
import { User } from './User'

View File

@ -27,8 +27,6 @@ import { Decimal } from 'decimal.js-light'
import { Arg, Args, Authorized, Ctx, Int, Mutation, Query, Resolver } from 'type-graphql'
import { RIGHTS } from '@/auth/RIGHTS'
import { decode, encode, verify } from '@/auth/jwt/JWT'
import { RedeemJwtPayloadType } from '@/auth/jwt/payloadtypes/RedeemJwtPayloadType'
import {
EVENT_CONTRIBUTION_LINK_REDEEM,
EVENT_TRANSACTION_LINK_CREATE,
@ -43,13 +41,12 @@ import {
} from '@/util/InterruptiveSleepManager'
import { TRANSACTIONS_LOCK } from '@/util/TRANSACTIONS_LOCK'
import { TRANSACTION_LINK_LOCK } from '@/util/TRANSACTION_LINK_LOCK'
import { calculateDecay } from 'shared'
import { fullName } from '@/util/utilities'
import { calculateBalance } from '@/util/validate'
import { calculateDecay, decode, DisburseJwtPayloadType, encode, RedeemJwtPayloadType, verify } from 'shared'
import { DisburseJwtPayloadType } from '@/auth/jwt/payloadtypes/DisburseJwtPayloadType'
import { Logger, getLogger } from 'log4js'
import { LOG4JS_BASE_CATEGORY_NAME } from '@/config/const'
import { getLogger, Logger } from 'log4js'
import { executeTransaction } from './TransactionResolver'
import {
getAuthenticatedCommunities,
@ -579,7 +576,7 @@ export class TransactionLinkResolver {
throw new LogError('Sender community UUID is not set')
}
// now with the sender community UUID the jwt token can be verified
const verifiedJwtPayload = await verify(code, senderCom.communityUuid)
const verifiedJwtPayload = await verify('handshakeID', code, senderCom.communityUuid)
logger.debug(
'TransactionLinkResolver.queryRedeemJwtLink... nach verify verifiedJwtPayload=',
verifiedJwtPayload,

View File

@ -5,12 +5,14 @@ import { sendTransactionsToDltConnector } from './apis/dltConnector/sendTransact
import { CONFIG } from './config'
import { startValidateCommunities } from './federation/validateCommunities'
import { createServer } from './server/createServer'
import { writeJwtKeyPairInHomeCommunity } from './federation/validateCommunities'
import { initLogging } from './server/logger'
async function main() {
initLogging()
const { app } = await createServer(getLogger('apollo'))
await writeJwtKeyPairInHomeCommunity()
app.listen(CONFIG.PORT, () => {
// biome-ignore lint/suspicious/noConsole: no need for logging the start message
console.log(`Server is running at http://localhost:${CONFIG.PORT}`)

View File

@ -58,6 +58,7 @@ const run = async () => {
const { con } = server
await cleanDB()
logger.info('##seed## clean database successful...')
logger.info(`crypto worker enabled: ${CONFIG.USE_CRYPTO_WORKER}`)
// seed home community
await writeHomeCommunityEntry()

View File

@ -6,6 +6,7 @@
"dependencies": {
"auto-changelog": "^2.4.0",
"cross-env": "^7.0.3",
"jose": "^4.14.4",
"turbo": "^2.5.0",
"uuid": "^8.3.2",
},

View File

@ -21,7 +21,8 @@
"test": "bun test",
"test:debug": "bun test --inspect-brk",
"lint": "biome check --error-on-warnings .",
"lint:fix": "biome check --error-on-warnings . --write"
"lint:fix": "biome check --error-on-warnings . --write",
"clear": "rm -rf node_modules && rm -rf build && rm -rf .turbo"
},
"devDependencies": {
"@biomejs/biome": "2.0.0",

View File

@ -21,17 +21,21 @@
"test:debug": "bun test --inspect-brk",
"typecheck": "tsc --noEmit",
"lint": "biome check --error-on-warnings .",
"lint:fix": "biome check --error-on-warnings . --write"
"lint:fix": "biome check --error-on-warnings . --write",
"clear": "rm -rf node_modules && rm -rf build && rm -rf .turbo"
},
"devDependencies": {
"@biomejs/biome": "2.0.0",
"@types/node": "^17.0.21",
"type-graphql": "^1.1.1",
"typescript": "^4.9.5"
},
"dependencies": {
"database": "*",
"esbuild": "^0.25.2",
"jose": "^4.14.4",
"log4js": "^6.9.1",
"shared": "*",
"zod": "^3.25.61"
},
"engines": {

View File

@ -1 +1 @@
export const LOG4JS_BASE_CATEGORY_NAME = 'core'
export const LOG4JS_BASE_CATEGORY_NAME = 'core'

View File

@ -0,0 +1,42 @@
import { EncryptedTransferArgs } from '../model/EncryptedTransferArgs'
import { JwtPayloadType } from 'shared'
import { Community as DbCommunity } from 'database'
import { getLogger } from 'log4js'
import { CommunityLoggingView, getHomeCommunity } from 'database'
import { verifyAndDecrypt } from 'shared'
import { LOG4JS_BASE_CATEGORY_NAME } from '../../config/const'
const logger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.graphql.resolver.util.interpretEncryptedTransferArgs`)
export const interpretEncryptedTransferArgs = async (args: EncryptedTransferArgs): Promise<JwtPayloadType | null> => {
const methodLogger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.graphql.resolver.util.interpretEncryptedTransferArgs-method`)
methodLogger.addContext('handshakeID', args.handshakeID)
methodLogger.debug('interpretEncryptedTransferArgs()... args:', args)
// first find with args.publicKey the community 'requestingCom', which starts the request
const requestingCom = await DbCommunity.findOneBy({ publicKey: Buffer.from(args.publicKey, 'hex') })
if (!requestingCom) {
const errmsg = `unknown requesting community with publicKey ${args.publicKey}`
methodLogger.error(errmsg)
methodLogger.removeContext('handshakeID')
throw new Error(errmsg)
}
if (!requestingCom.publicJwtKey) {
const errmsg = `missing publicJwtKey of requesting community with publicKey ${args.publicKey}`
methodLogger.error(errmsg)
methodLogger.removeContext('handshakeID')
throw new Error(errmsg)
}
methodLogger.debug(`found requestingCom:`, new CommunityLoggingView(requestingCom))
// verify the signing of args.jwt with homeCom.privateJwtKey and decrypt args.jwt with requestingCom.publicJwtKey
const homeCom = await getHomeCommunity()
const jwtPayload = await verifyAndDecrypt(args.handshakeID, args.jwt, homeCom!.privateJwtKey!, requestingCom.publicJwtKey) as JwtPayloadType
if (!jwtPayload) {
const errmsg = `invalid payload of community with publicKey ${args.publicKey}`
methodLogger.error(errmsg)
methodLogger.removeContext('handshakeID')
throw new Error(errmsg)
}
methodLogger.debug('jwtPayload', jwtPayload)
methodLogger.removeContext('handshakeID')
return jwtPayload
}

View File

@ -1,10 +1,13 @@
import { Field, InputType } from 'type-graphql'
@InputType()
export class OpenConnectionArgs {
export class EncryptedTransferArgs {
@Field(() => String)
handshakeID: string
@Field(() => String)
publicKey: string
@Field(() => String)
url: string
jwt: string
}

View File

@ -1 +1,3 @@
export * from './validation/user'
export * from './validation/user'
export * from './graphql/logic/interpretEncryptedTransferArgs'
export * from './graphql/model/EncryptedTransferArgs'

View File

@ -0,0 +1,18 @@
/* MIGRATION TO ADD JWT-KEYPAIR IN COMMUNITY TABLE
*
* This migration adds fields for the jwt-keypair in the community.table
*/
export async function upgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
await queryFn(
'ALTER TABLE `communities` ADD COLUMN `public_jwt_key` varchar(512) DEFAULT NULL AFTER `gms_api_key`;',
)
await queryFn(
'ALTER TABLE `communities` ADD COLUMN `private_jwt_key` varchar(2048) DEFAULT NULL AFTER `public_jwt_key`;',
)
}
export async function downgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
await queryFn('ALTER TABLE `communities` DROP COLUMN `public_jwt_key`;')
await queryFn('ALTER TABLE `communities` DROP COLUMN `private_jwt_key`;')
}

View File

@ -19,7 +19,7 @@
"typecheck": "tsc --noEmit",
"lint": "biome check --error-on-warnings .",
"lint:fix": "biome check --error-on-warnings . --write",
"clear": "cross-env TZ=UTC tsx migration/index.ts clear",
"clearDB": "cross-env TZ=UTC tsx migration/index.ts clear",
"test": "cross-env TZ=UTC NODE_ENV=development DB_DATABASE=gradido_test vitest --reporter verbose --no-file-parallelism run",
"test:debug": "cross-env TZ=UTC NODE_ENV=development DB_DATABASE=gradido_test node --inspect-brk node_modules/.bin/jest --bail --runInBand --forceExit --detectOpenHandles",
"up": "cross-env TZ=UTC tsx migration/index.ts up",
@ -28,7 +28,8 @@
"up:test": "cross-env TZ=UTC DB_DATABASE=gradido_test tsx migration/index.ts up",
"up:backend_test": "cross-env TZ=UTC DB_DATABASE=gradido_test_backend tsx migration/index.ts up",
"up:federation_test": "cross-env TZ=UTC DB_DATABASE=gradido_test_federation tsx migration/index.ts up",
"up:dht_test": "cross-env TZ=UTC DB_DATABASE=gradido_test_dht tsx migration/index.ts up"
"up:dht_test": "cross-env TZ=UTC DB_DATABASE=gradido_test_dht tsx migration/index.ts up",
"clear": "rm -rf node_modules && rm -rf build && rm -rf .turbo"
},
"devDependencies": {
"@biomejs/biome": "2.0.0",

View File

@ -54,6 +54,12 @@ export class Community extends BaseEntity {
@Column({ name: 'gms_api_key', type: 'varchar', length: 512, nullable: true, default: null })
gmsApiKey: string | null
@Column({ name: 'public_jwt_key', type: 'varchar', length: 512, nullable: true })
publicJwtKey: string | null
@Column({ name: 'private_jwt_key', type: 'varchar', length: 2048, nullable: true })
privateJwtKey: string | null
@Column({
name: 'location',
type: 'geometry',

View File

@ -13,6 +13,7 @@ export class CommunityLoggingView extends AbstractLoggingView {
foreign: this.self.foreign,
url: this.self.url,
publicKey: this.self.publicKey.toString(this.bufferStringFormat),
publicJwtKey: this.self.publicJwtKey,
communityUuid: this.self.communityUuid,
authenticatedAt: this.dateToString(this.self.authenticatedAt),
name: this.self.name,

View File

@ -282,7 +282,7 @@ bun install
# build all modules
log_step 'build all modules'
turbo build --env-mode=loose
turbo build --env-mode=loose --concurrency=$(nproc)
# database
log_step 'Updating database'

View File

@ -17,7 +17,8 @@
"lint:fix": "biome check --error-on-warnings . --write",
"test": "cross-env TZ=UTC NODE_ENV=development DB_DATABASE=gradido_test_dht jest --verbose --runInBand --forceExit --detectOpenHandles",
"test:debug": "cross-env TZ=UTC NODE_ENV=development DB_DATABASE=gradido_test_dht node --inspect-brk node_modules/.bin/jest --bail --runInBand --forceExit --detectOpenHandles",
"test:coverage": "cross-env TZ=UTC NODE_ENV=development DB_DATABASE=gradido_test_dht jest --coverage --runInBand --forceExit --detectOpenHandles"
"test:coverage": "cross-env TZ=UTC NODE_ENV=development DB_DATABASE=gradido_test_dht jest --coverage --runInBand --forceExit --detectOpenHandles",
"clear": "rm -rf node_modules && rm -rf build && rm -rf .turbo"
},
"dependencies": {
"cross-env": "^7.0.3",

View File

@ -3,4 +3,4 @@ cypress/screenshots/
cypress/videos/
cypress/reports/
cucumber-messages.ndjson
**/target

View File

@ -1,42 +0,0 @@
###############################################################################
# Dockerfile to create a ready-to-use Playwright Docker image for end-to-end
# testing.
#
# To avoid hardcoded versoning of Playwright, this Dockerfile is a custom
# version of the ready-to-use Dockerfile privided by Playwright developement
# (https://github.com/microsoft/playwright/blob/main/utils/docker/Dockerfile.focal)
#
# Here the latest stable versions of the browsers Chromium, Firefox, and Webkit
# (Safari) are installed, icluding all dependencies based on Ubuntu specified by
# Playwright developement.
###############################################################################
FROM ubuntu:focal
# set a timezone for the Playwright browser dependency installation
ARG TZ=Europe/Berlin
ARG DOCKER_WORKDIR=/tests/
WORKDIR $DOCKER_WORKDIR
# package manager preparation
RUN apt-get -qq update && apt-get install -qq -y curl gpg > /dev/null
# for Node.js
RUN curl -sL https://deb.nodesource.com/setup_16.x | bash -
# for Yarn
RUN curl -sL https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - && \
echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list
# install node v16 and Yarn
RUN apt-get -qq update && apt-get install -qq -y nodejs yarn
COPY tests/package.json tests/yarn.lock $DOCKER_WORKDIR
# install Playwright with all dependencies
# for the browsers chromium, firefox, and webkit
RUN yarn install && yarn playwright install --with-deps
# clean up
RUN rm -rf /var/lib/apt/lists/* && apt-get -qq clean
COPY tests/ $DOCKER_WORKDIR

View File

@ -1,24 +0,0 @@
# Gradido End-to-End Testing with [Playwright](https://playwright.dev/) (CI-ready via Docker)
A sample setup to show-case Playwright (using Typescript) as an end-to-end testing tool for Gradido runniing in a Docker container.
Here we have a simple UI-based happy path login test running against the DEV system.
## Precondition
Since dependencies and configurations for Github Actions integration is not set up yet, please run in root directory
```bash
docker-compose up
```
to boot up the DEV system, before running the test.
## Execute the test
```bash
# build a Docker image from the Dockerfile
docker build -t gradido_e2e-tests-playwright .
# run the Docker container and execute the given tests
docker run -it --network=host gradido_e2e-tests-playwright yarn playwright-e2e-tests
```

View File

@ -0,0 +1,12 @@
# gradido-e2e-tests-playwright with java
Experimental End-to-end tests with Playwright and Java
## Prerequisites
- Java 17
- Maven
- Playwright
- Gradle
- Git

View File

@ -0,0 +1,52 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>net.gradido</groupId>
<artifactId>gradido-e2e-tests-playwright</artifactId>
<version>0.0.1</version>
<name>Gradido Playwright End-to-End Tests in Java</name>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
</properties>
<dependencies>
<dependency>
<groupId>com.microsoft.playwright</groupId>
<artifactId>playwright</artifactId>
<version>1.52.0</version>
</dependency>
<!-- JUnit Jupiter API & Engine -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.0</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.10.1</version>
<!-- References to interface static methods are allowed only at source level 1.8 or above -->
<configuration>
<source>17</source>
<target>17</target>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.0.0-M9</version>
<configuration>
<includes>
<include>**/*Test*.java</include>
</includes>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@ -0,0 +1,69 @@
package net.gradido.e2e.base;
import com.microsoft.playwright.Browser;
import com.microsoft.playwright.BrowserContext;
import com.microsoft.playwright.Playwright;
import com.microsoft.playwright.Page;
import com.microsoft.playwright.Tracing;
import org.junit.jupiter.api.*;
import java.nio.file.Path;
import java.nio.file.Paths;
// Subclasses will inherit PER_CLASS behavior.
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
public class BaseTest {
protected Playwright playwright;
protected Browser browser;
@BeforeAll
void setUpAll() {
playwright = Playwright.create();
browser = playwright.chromium().launch();
}
@AfterAll
void tearDownAll() {
playwright.close();
}
// New instance for each test method.
protected BrowserContext context;
protected Page page;
protected Path currentTracePath;
@BeforeEach
void setUp(TestInfo testInfo) {
context = browser.newContext(
new Browser.NewContextOptions().setBaseURL("http://localhost:3000")
);
context.route("**/*", route -> {
String url = route.request().url();
// we skip fontawesome and googleapis requests, we don't need them for functions test, but they cost time
if (url.contains("use.fontawesome.com") || url.contains("fonts.googleapis.com")) {
route.abort();
return;
}
route.resume();
});
// Start tracing before creating
String testName = testInfo.getDisplayName().replaceAll("[^a-zA-Z0-9]", "");
currentTracePath = Paths.get("target/traces/" + testName + ".zip");
context.tracing().start(new Tracing.StartOptions()
.setScreenshots(true)
.setSnapshots(true)
.setSources(true));
page = context.newPage();
}
@AfterEach
void tearDown() {
// Stop tracing and export it into a zip archive.
context.tracing().stop(new Tracing.StopOptions().setPath(currentTracePath));
page.close();
context.close();
}
}

View File

@ -0,0 +1,26 @@
package net.gradido.e2e.components;
import com.microsoft.playwright.Locator;
import com.microsoft.playwright.Page;
import static org.junit.jupiter.api.Assertions.assertTrue;
public class Toasts {
public final Locator toastSlot;
public final Locator toastTypeError;
public final Locator toastTitle;
public final Locator toastMessage;
public Toasts(Page page) {
toastSlot = page.locator("#__BVID__toaster-container");
toastTypeError = toastSlot.locator(".toast.text-bg-danger");
toastTitle = toastTypeError.locator(".gdd-toaster-title");
toastMessage = toastTypeError.locator(".gdd-toaster-body");
}
public void assertErrorToastVisible() {
toastTypeError.waitFor();
assertTrue(toastTitle.isVisible());
assertTrue(toastMessage.isVisible());
}
}

View File

@ -0,0 +1,32 @@
package net.gradido.e2e.pages;
import com.microsoft.playwright.Locator;
import com.microsoft.playwright.Page;
import com.microsoft.playwright.Response;
public class LoginPage {
private final Page page;
private final Locator emailInput;
private final Locator passwordInput;
private final Locator submitButton;
public LoginPage(Page page) {
this.page = page;
emailInput = page.locator("input[name='email']");
passwordInput = page.locator("input[name='password']");
submitButton = page.locator("button[type='submit']");
}
public void login(String email, String password) {
emailInput.fill(email);
passwordInput.fill(password);
Response response = page.waitForResponse("**/graphql", () -> {
submitButton.click();
});
}
public void navigate() {
page.navigate("http://localhost:3000/login");
}
}

View File

@ -0,0 +1,26 @@
package net.gradido.e2e.tests;
import net.gradido.e2e.base.BaseTest;
import net.gradido.e2e.pages.LoginPage;
import net.gradido.e2e.components.Toasts;
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
public class InvalidLoginTest extends BaseTest {
private LoginPage loginPage;
@BeforeEach
void initPageObjects() {
loginPage = new LoginPage(page);
}
@Test
void invalidUserSeesError() {
loginPage.navigate();
loginPage.login("peter@lustig.de", "wrongpass");
Toasts toast = new Toasts(page);
toast.assertErrorToastVisible();
}
}

View File

@ -0,0 +1,28 @@
package net.gradido.e2e.tests;
import net.gradido.e2e.base.BaseTest;
import net.gradido.e2e.pages.LoginPage;
import net.gradido.e2e.components.Toasts;
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
public class ValidLoginTest extends BaseTest {
private LoginPage loginPage;
@BeforeEach
void initPageObjects() {
loginPage = new LoginPage(page);
}
@Test
void validUserCanLogin() {
loginPage.navigate();
loginPage.login("peter@lustig.de", "Aa12345_");
page.waitForURL("http://localhost:3000/overview");
assertTrue(page.url().contains("/overview"));
}
}

View File

@ -0,0 +1,5 @@
junit.jupiter.execution.parallel.enabled = true
junit.jupiter.execution.parallel.mode.default = same_thread
junit.jupiter.execution.parallel.mode.classes.default = concurrent
junit.jupiter.execution.parallel.config.strategy=dynamic
junit.jupiter.execution.parallel.config.dynamic.factor=0.5

View File

@ -1,10 +0,0 @@
import { FullConfig } from '@playwright/test';
async function globalSetup(config: FullConfig) {
process.env.EMAIL = 'bibi@bloxberg.de';
process.env.PASSWORD = 'Aa12345_';
process.env.GMS_ACTIVE = false;
process.env.HUMHUB_ACTIVE = false;
}
export default globalSetup;

View File

@ -1,15 +0,0 @@
import { test, expect } from '@playwright/test';
import { LoginPage } from './models/login_page';
import { WelcomePage } from './models/welcome_page';
test('Gradido login test (happy path)', async ({ page }) => {
const { EMAIL, PASSWORD } = process.env;
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.enterEmail(EMAIL);
await loginPage.enterPassword(PASSWORD);
await loginPage.submitLogin();
// assertions
await expect(page).toHaveURL('./overview');
});

View File

@ -1,33 +0,0 @@
import { expect, test, Locator, Page } from '@playwright/test';
export class LoginPage {
readonly page: Page;
readonly url: string;
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitBtn: Locator;
constructor(page: Page) {
this.page = page;
this.url = './login';
this.emailInput = page.locator('id=Email-input-field');
this.passwordInput = page.locator('id=Password-input-field');
this.submitBtn = page.locator('text=Login');
}
async goto() {
await this.page.goto(this.url);
}
async enterEmail(email: string) {
await this.emailInput.fill(email);
}
async enterPassword(password: string) {
await this.passwordInput.fill(password);
}
async submitLogin() {
await this.submitBtn.click();
}
}

View File

@ -1,13 +0,0 @@
import { expect, Locator, Page } from '@playwright/test';
export class WelcomePage {
readonly page: Page;
readonly url: string;
readonly profileLink: Locator;
constructor(page: Page){
this.page = page;
this.url = './overview';
this.profileLink = page.locator('href=/profile');
}
}

View File

@ -1,21 +0,0 @@
import type { PlaywrightTestConfig } from '@playwright/test';
const config: PlaywrightTestConfig = {
globalSetup: require.resolve('./global-setup'),
ignoreHTTPSErrors: true,
locale: 'de-DE',
reporter: process.env.CI ? 'github' : 'list',
retries: 1,
screenshot: 'only-on-failure',
testDir: '.',
timeout: 30000,
trace: 'on-first-retry',
video: 'never',
viewport: { width: 1280, height: 720 },
use: {
baseURL: process.env.URL || 'http://127.0.0.1:3000',
browserName: 'webkit',
},
};
export default config;

View File

@ -0,0 +1,7 @@
# Playwright
node_modules/
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/

View File

@ -0,0 +1,22 @@
import type { Locator, Page } from '@playwright/test'
import { expect } from '@playwright/test'
export class Toasts {
public readonly toastSlot: Locator
public readonly toastTypeError: Locator
public readonly toastTitle: Locator
public readonly toastMessage: Locator
constructor(page: Page) {
this.toastSlot = page.locator('#__BVID__toaster-container')
this.toastTypeError = this.toastSlot.locator('.toast.text-bg-danger')
this.toastTitle = this.toastTypeError.locator('.gdd-toaster-title')
this.toastMessage = this.toastTypeError.locator('.gdd-toaster-body')
}
async assertErrorToastVisible(): Promise<void> {
await this.toastTypeError.waitFor({ state: 'visible' })
expect(this.toastTitle).toBeVisible()
expect(this.toastMessage).toBeVisible()
}
}

View File

@ -0,0 +1 @@
export const FRONTEND_URL = "http://localhost:3000"

View File

@ -0,0 +1,13 @@
{
"name": "typescript",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"devDependencies": {
"@playwright/test": "^1.53.1",
"@types/node": "^24.0.7"
},
"scripts": {
"test": "playwright test --reporter=list"
}
}

View File

@ -0,0 +1,28 @@
import type { Locator, Page } from '@playwright/test'
import { FRONTEND_URL } from '../config'
export class LoginPage {
readonly page: Page
readonly emailInput: Locator
readonly passwordInput: Locator
readonly submitButton: Locator
constructor(page: Page) {
this.page = page
this.emailInput = page.locator('#email-input-field')
this.passwordInput = page.locator('#password-input-field')
this.submitButton = page.locator("button[type='submit']")
}
async goto() {
await this.page.goto(`${FRONTEND_URL}/login`)
}
async login(email: string, password: string) {
await this.emailInput.fill(email)
await this.passwordInput.fill(password)
const responsePromise = this.page.waitForResponse('**/graphql')
await this.submitButton.click()
await responsePromise
}
}

View File

@ -0,0 +1,82 @@
import { defineConfig, devices } from '@playwright/test';
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// import dotenv from 'dotenv';
// import path from 'path';
// dotenv.config({ path: path.resolve(__dirname, '.env') });
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './tests',
// Glob patterns or regular expressions that match test files.
testMatch: '*',
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: 2,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? '90%' : '75%',
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
// baseURL: 'http://localhost:3000',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
video: 'on-first-retry',
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: { ...devices['Pixel 5'] },
// },
// {
// name: 'Mobile Safari',
// use: { ...devices['iPhone 12'] },
// },
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
// },
{
name: 'Google Chrome',
use: { ...devices['Desktop Chrome'], channel: 'chrome' },
},
],
/* Run your local dev server before starting the tests */
// webServer: {
// command: 'npm run start',
// url: 'http://localhost:3000',
// reuseExistingServer: !process.env.CI,
// },
});

View File

@ -0,0 +1,13 @@
import { test, expect } from '@playwright/test'
import { LoginPage } from '../pages/LoginPage'
import { Toasts } from '../components/Toasts'
test('invalid login', async ({ page }) => {
const loginPage = new LoginPage(page)
await loginPage.goto()
await loginPage.login('peter@lustig.de', 'wrongpass')
const toast = new Toasts(page)
await toast.assertErrorToastVisible()
await page.waitForTimeout(50)
})

View File

@ -0,0 +1,11 @@
import { test, expect } from '@playwright/test'
import { LoginPage } from '../pages/LoginPage'
import { FRONTEND_URL } from '../config'
test('valid login', async ({ page }) => {
const loginPage = new LoginPage(page)
await loginPage.goto()
await loginPage.login('peter@lustig.de', 'Aa12345_')
await page.waitForURL(`${FRONTEND_URL}/overview`)
expect(page.url()).toContain('/overview')
})

View File

@ -0,0 +1,41 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
"@playwright/test@^1.53.1":
version "1.53.1"
resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.53.1.tgz#3ad5a2ce334b4a78390fd91e0a9d8423c09bc808"
integrity sha512-Z4c23LHV0muZ8hfv4jw6HngPJkbbtZxTkxPNIg7cJcTc9C28N/p2q7g3JZS2SiKBBHJ3uM1dgDye66bB7LEk5w==
dependencies:
playwright "1.53.1"
"@types/node@^24.0.7":
version "24.0.7"
resolved "https://registry.yarnpkg.com/@types/node/-/node-24.0.7.tgz#ee580f7850c7eabaeef61ef96b8d8c04fdf94f53"
integrity sha512-YIEUUr4yf8q8oQoXPpSlnvKNVKDQlPMWrmOcgzoduo7kvA2UF0/BwJ/eMKFTiTtkNL17I0M6Xe2tvwFU7be6iw==
dependencies:
undici-types "~7.8.0"
fsevents@2.3.2:
version "2.3.2"
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a"
integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==
playwright-core@1.53.1:
version "1.53.1"
resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.53.1.tgz#0b6f7a2006ccb6126ffcc3e3b2fa9efda23b6638"
integrity sha512-Z46Oq7tLAyT0lGoFx4DOuB1IA9D1TPj0QkYxpPVUnGDqHHvDpCftu1J2hM2PiWsNMoZh8+LQaarAWcDfPBc6zg==
playwright@1.53.1:
version "1.53.1"
resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.53.1.tgz#86fb041b237a6868d163c87c4b9737fd1cac145e"
integrity sha512-LJ13YLr/ocweuwxyGf1XNFWIU4M2zUSo149Qbp+A4cpwDjsxRPj7k6H25LBrEHiEwxvRbD8HdwvQmRMSvquhYw==
dependencies:
playwright-core "1.53.1"
optionalDependencies:
fsevents "2.3.2"
undici-types@~7.8.0:
version "7.8.0"
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.8.0.tgz#de00b85b710c54122e44fbfd911f8d70174cd294"
integrity sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==

View File

@ -18,7 +18,8 @@
"test:debug": "cross-env TZ=UTC NODE_ENV=development DB_DATABASE=gradido_test_federation node --inspect-brk node_modules/.bin/jest --bail --runInBand --forceExit --detectOpenHandles",
"test:coverage": "cross-env TZ=UTC NODE_ENV=development DB_DATABASE=gradido_test_federation jest --coverage --runInBand --forceExit --detectOpenHandles",
"lint": "biome check --error-on-warnings .",
"lint:fix": "biome check --error-on-warnings . --write"
"lint:fix": "biome check --error-on-warnings . --write",
"clear": "rm -rf node_modules && rm -rf build && rm -rf .turbo"
},
"dependencies": {
"cross-env": "^7.0.3",
@ -41,6 +42,7 @@
"await-semaphore": "0.1.3",
"class-validator": "^0.13.2",
"config-schema": "*",
"core": "*",
"cors": "2.8.5",
"database": "*",
"decimal.js-light": "^2.5.1",

View File

@ -1,15 +1,12 @@
import { FederatedCommunity as DbFederatedCommunity } from 'database'
import { GraphQLClient } from 'graphql-request'
import { getLogger } from 'log4js'
import { getLogger, Logger } from 'log4js'
import { LOG4JS_BASE_CATEGORY_NAME } from '@/config/const'
import { AuthenticationArgs } from '@/graphql/api/1_0/model/AuthenticationArgs'
import { OpenConnectionCallbackArgs } from '@/graphql/api/1_0/model/OpenConnectionCallbackArgs'
import { EncryptedTransferArgs } from 'core/src/graphql/model/EncryptedTransferArgs'
import { authenticate } from './query/authenticate'
import { openConnectionCallback } from './query/openConnectionCallback'
const logger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.client.1_0.AuthenticationClient`)
export class AuthenticationClient {
dbCom: DbFederatedCommunity
endpoint: string
@ -29,36 +26,41 @@ export class AuthenticationClient {
})
}
async openConnectionCallback(args: OpenConnectionCallbackArgs): Promise<boolean> {
logger.debug('openConnectionCallback with endpoint', this.endpoint, args)
async openConnectionCallback(args: EncryptedTransferArgs): Promise<boolean> {
const methodLogger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.client.1_0.AuthenticationClient.openConnectionCallback`)
methodLogger.addContext('handshakeID', args.handshakeID)
methodLogger.debug('openConnectionCallback with endpoint', this.endpoint, args)
try {
const { data } = await this.client.rawRequest<any>(openConnectionCallback, { args })
methodLogger.debug('after openConnectionCallback: data:', data)
if (data && data.openConnectionCallback) {
logger.warn('openConnectionCallback without response data from endpoint', this.endpoint)
if (!data || !data.openConnectionCallback) {
methodLogger.warn('openConnectionCallback without response data from endpoint', this.endpoint)
return false
}
logger.debug('openConnectionCallback successfully started with endpoint', this.endpoint)
methodLogger.debug('openConnectionCallback successfully started with endpoint', this.endpoint)
return true
} catch (err) {
logger.error('error on openConnectionCallback', err)
methodLogger.error('error on openConnectionCallback', err)
}
return false
}
async authenticate(args: AuthenticationArgs): Promise<string | null> {
logger.debug('authenticate with endpoint=', this.endpoint)
async authenticate(args: EncryptedTransferArgs): Promise<string | null> {
const methodLogger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.client.1_0.AuthenticationClient.authenticate`)
methodLogger.addContext('handshakeID', args.handshakeID)
methodLogger.debug('authenticate with endpoint=', this.endpoint)
try {
const { data } = await this.client.rawRequest<any>(authenticate, { args })
logger.debug('after authenticate: data:', data)
methodLogger.debug('after authenticate: data:', data)
const authUuid: string = data?.authenticate
if (authUuid) {
logger.debug('received authenticated uuid', authUuid)
methodLogger.debug('received authenticated uuid', authUuid)
return authUuid
}
} catch (err) {
logger.error('authenticate failed', {
methodLogger.error('authenticate failed', {
endpoint: this.endpoint,
err,
})

View File

@ -1,7 +1,7 @@
import { gql } from 'graphql-request'
export const authenticate = gql`
mutation ($args: AuthenticationArgs!) {
mutation ($args: EncryptedTransferArgs!) {
authenticate(data: $args)
}
`

View File

@ -1,7 +1,7 @@
import { gql } from 'graphql-request'
export const openConnectionCallback = gql`
mutation ($args: OpenConnectionCallbackArgs!) {
mutation ($args: EncryptedTransferArgs!) {
openConnectionCallback(data: $args)
}
`

View File

@ -12,6 +12,7 @@ export class GetPublicCommunityInfoResultLoggingView extends AbstractLoggingView
description: this.self.description,
creationDate: this.dateToString(this.self.creationDate),
publicKey: this.self.publicKey,
publicJwtKey: this.self.publicJwtKey,
}
}
}

View File

@ -6,6 +6,9 @@ import { Field, ObjectType } from 'type-graphql'
export class GetPublicCommunityInfoResult {
constructor(dbCom: DbCommunity) {
this.publicKey = dbCom.publicKey.toString('hex')
if (dbCom.publicJwtKey) {
this.publicJwtKey = dbCom.publicJwtKey
}
this.name = dbCom.name
this.description = dbCom.description
this.creationDate = dbCom.creationDate
@ -22,4 +25,7 @@ export class GetPublicCommunityInfoResult {
@Field(() => String)
publicKey: string
@Field(() => String)
publicJwtKey: string
}

View File

@ -1,10 +0,0 @@
import { Field, InputType } from 'type-graphql'
@InputType()
export class OpenConnectionCallbackArgs {
@Field(() => String)
oneTimeCode: string
@Field(() => String)
url: string
}

View File

@ -1,17 +1,16 @@
import { CONFIG } from '@/config'
import { LogError } from '@/server/LogError'
import { LOG4JS_BASE_CATEGORY_NAME } from '@/config/const'
import { EncryptedTransferArgs, interpretEncryptedTransferArgs } from 'core'
import {
CommunityLoggingView,
Community as DbCommunity,
FederatedCommunity as DbFedCommunity,
FederatedCommunityLoggingView,
getHomeCommunity,
} from 'database'
import { getLogger } from 'log4js'
import { AuthenticationJwtPayloadType, AuthenticationResponseJwtPayloadType, encryptAndSign, OpenConnectionCallbackJwtPayloadType, OpenConnectionJwtPayloadType } from 'shared'
import { Arg, Mutation, Resolver } from 'type-graphql'
import { LOG4JS_BASE_CATEGORY_NAME } from '@/config/const'
import { AuthenticationArgs } from '../model/AuthenticationArgs'
import { OpenConnectionArgs } from '../model/OpenConnectionArgs'
import { OpenConnectionCallbackArgs } from '../model/OpenConnectionCallbackArgs'
import { startAuthentication, startOpenConnectionCallback } from '../util/authenticateCommunity'
const logger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.graphql.api.1_0.resolver.AuthenticationResolver`)
@ -21,67 +20,118 @@ export class AuthenticationResolver {
@Mutation(() => Boolean)
async openConnection(
@Arg('data')
args: OpenConnectionArgs,
args: EncryptedTransferArgs,
): Promise<boolean> {
const pubKeyBuf = Buffer.from(args.publicKey, 'hex')
logger.debug(`openConnection() via apiVersion=1_0:`, args)
// first find with args.publicKey the community 'comA', which starts openConnection request
const comA = await DbCommunity.findOneBy({
publicKey: pubKeyBuf, // Buffer.from(args.publicKey),
})
if (!comA) {
throw new LogError(`unknown requesting community with publicKey`, pubKeyBuf.toString('hex'))
const methodLogger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.graphql.api.1_0.resolver.AuthenticationResolver.openConnection`)
methodLogger.addContext('handshakeID', args.handshakeID)
methodLogger.debug(`openConnection() via apiVersion=1_0:`, args)
const openConnectionJwtPayload = await interpretEncryptedTransferArgs(args) as OpenConnectionJwtPayloadType
methodLogger.debug('openConnectionJwtPayload', openConnectionJwtPayload)
if (!openConnectionJwtPayload) {
const errmsg = `invalid OpenConnection payload of requesting community with publicKey` + args.publicKey
methodLogger.error(errmsg)
methodLogger.removeContext('handshakeID')
throw new Error(errmsg)
}
logger.debug(`found requestedCom:`, new CommunityLoggingView(comA))
// biome-ignore lint/complexity/noVoid: no await to respond immediately and invoke callback-request asynchronously
void startOpenConnectionCallback(args, comA, CONFIG.FEDERATION_API)
if (openConnectionJwtPayload.tokentype !== OpenConnectionJwtPayloadType.OPEN_CONNECTION_TYPE) {
const errmsg = `invalid tokentype of community with publicKey` + args.publicKey
methodLogger.error(errmsg)
methodLogger.removeContext('handshakeID')
throw new Error(errmsg)
}
if (!openConnectionJwtPayload.url) {
const errmsg = `invalid url of community with publicKey` + args.publicKey
methodLogger.error(errmsg)
methodLogger.removeContext('handshakeID')
throw new Error(errmsg)
}
methodLogger.debug(`vor DbFedCommunity.findOneByOrFail()...`, { publicKey: args.publicKey })
const fedComA = await DbFedCommunity.findOneByOrFail({ publicKey: Buffer.from(args.publicKey, 'hex') })
methodLogger.debug(`nach DbFedCommunity.findOneByOrFail()...`, fedComA)
methodLogger.debug('fedComA', new FederatedCommunityLoggingView(fedComA))
if (!openConnectionJwtPayload.url.startsWith(fedComA.endPoint)) {
const errmsg = `invalid url of community with publicKey` + args.publicKey
methodLogger.error(errmsg)
methodLogger.removeContext('handshakeID')
throw new Error(errmsg)
}
// no await to respond immediately and invoke callback-request asynchronously
void startOpenConnectionCallback(args.handshakeID, args.publicKey, CONFIG.FEDERATION_API)
methodLogger.debug('openConnection() successfully initiated callback and returns true immediately...')
methodLogger.removeContext('handshakeID')
return true
}
@Mutation(() => Boolean)
async openConnectionCallback(
@Arg('data')
args: OpenConnectionCallbackArgs,
args: EncryptedTransferArgs,
): Promise<boolean> {
logger.debug(`openConnectionCallback() via apiVersion=1_0 ...`, args)
// TODO decrypt args.url with homeCom.privateKey and verify signing with callbackFedCom.publicKey
const endPoint = args.url.slice(0, args.url.lastIndexOf('/') + 1)
const apiVersion = args.url.slice(args.url.lastIndexOf('/') + 1, args.url.length)
logger.debug(`search fedComB per:`, endPoint, apiVersion)
const methodLogger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.graphql.api.1_0.resolver.AuthenticationResolver.openConnectionCallback`)
methodLogger.addContext('handshakeID', args.handshakeID)
methodLogger.debug(`openConnectionCallback() via apiVersion=1_0 ...`, args)
// decrypt args.url with homeCom.privateJwtKey and verify signing with callbackFedCom.publicKey
const openConnectionCallbackJwtPayload = await interpretEncryptedTransferArgs(args) as OpenConnectionCallbackJwtPayloadType
if (!openConnectionCallbackJwtPayload) {
const errmsg = `invalid OpenConnectionCallback payload of requesting community with publicKey` + args.publicKey
methodLogger.error(errmsg)
methodLogger.removeContext('handshakeID')
throw new Error(errmsg)
}
const endPoint = openConnectionCallbackJwtPayload.url.slice(0, openConnectionCallbackJwtPayload.url.lastIndexOf('/') + 1)
const apiVersion = openConnectionCallbackJwtPayload.url.slice(openConnectionCallbackJwtPayload.url.lastIndexOf('/') + 1, openConnectionCallbackJwtPayload.url.length)
methodLogger.debug(`search fedComB per:`, endPoint, apiVersion)
const fedComB = await DbFedCommunity.findOneBy({ endPoint, apiVersion })
if (!fedComB) {
throw new LogError(`unknown callback community with url`, args.url)
const errmsg = `unknown callback community with url` + openConnectionCallbackJwtPayload.url
methodLogger.error(errmsg)
methodLogger.removeContext('handshakeID')
throw new Error(errmsg)
}
logger.debug(
methodLogger.debug(
`found fedComB and start authentication:`,
new FederatedCommunityLoggingView(fedComB),
)
// biome-ignore lint/complexity/noVoid: no await to respond immediately and invoke authenticate-request asynchronously
void startAuthentication(args.oneTimeCode, fedComB)
// no await to respond immediately and invoke authenticate-request asynchronously
void startAuthentication(args.handshakeID, openConnectionCallbackJwtPayload.oneTimeCode, fedComB)
methodLogger.debug('openConnectionCallback() successfully initiated authentication and returns true immediately...')
methodLogger.removeContext('handshakeID')
return true
}
@Mutation(() => String)
async authenticate(
@Arg('data')
args: AuthenticationArgs,
args: EncryptedTransferArgs,
): Promise<string | null> {
logger.debug(`authenticate() via apiVersion=1_0 ...`, args)
const authCom = await DbCommunity.findOneByOrFail({ communityUuid: args.oneTimeCode })
logger.debug('found authCom:', new CommunityLoggingView(authCom))
const methodLogger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.graphql.api.1_0.resolver.AuthenticationResolver.authenticate`)
methodLogger.addContext('handshakeID', args.handshakeID)
methodLogger.debug(`authenticate() via apiVersion=1_0 ...`, args)
const authArgs = await interpretEncryptedTransferArgs(args) as AuthenticationJwtPayloadType
if (!authArgs) {
const errmsg = `invalid authentication payload of requesting community with publicKey` + args.publicKey
methodLogger.error(errmsg)
methodLogger.removeContext('handshakeID')
throw new Error(errmsg)
}
const authCom = await DbCommunity.findOneByOrFail({ communityUuid: authArgs.oneTimeCode })
methodLogger.debug('found authCom:', new CommunityLoggingView(authCom))
if (authCom) {
// TODO decrypt args.uuid with authCom.publicKey
authCom.communityUuid = args.uuid
authCom.communityUuid = authArgs.uuid
authCom.authenticatedAt = new Date()
await DbCommunity.save(authCom)
logger.debug('store authCom.uuid successfully:', new CommunityLoggingView(authCom))
const homeCom = await DbCommunity.findOneByOrFail({ foreign: false })
// TODO encrypt homeCom.uuid with homeCom.privateKey
if (homeCom.communityUuid) {
return homeCom.communityUuid
methodLogger.debug('store authCom.uuid successfully:', new CommunityLoggingView(authCom))
const homeComB = await getHomeCommunity()
if (homeComB?.communityUuid) {
const responseArgs = new AuthenticationResponseJwtPayloadType(args.handshakeID,homeComB.communityUuid)
const responseJwt = await encryptAndSign(responseArgs, homeComB.privateJwtKey!, authCom.publicJwtKey!)
methodLogger.removeContext('handshakeID')
return responseJwt
}
}
methodLogger.removeContext('handshakeID')
return null
}
}

View File

@ -1,46 +1,50 @@
import { EncryptedTransferArgs } from 'core'
import {
CommunityLoggingView,
Community as DbCommunity,
FederatedCommunity as DbFedCommunity,
FederatedCommunityLoggingView,
getHomeCommunity,
} from 'database'
import { getLogger } from 'log4js'
import { OpenConnectionArgs } from '../model/OpenConnectionArgs'
import { OpenConnectionCallbackArgs } from '../model/OpenConnectionCallbackArgs'
import { validate as validateUUID, version as versionUUID } from 'uuid'
import { AuthenticationClientFactory } from '@/client/AuthenticationClientFactory'
import { randombytes_random } from 'sodium-native'
import { AuthenticationClient as V1_0_AuthenticationClient } from '@/client/1_0/AuthenticationClient'
import { LOG4JS_BASE_CATEGORY_NAME } from '@/config/const'
import { AuthenticationArgs } from '../model/AuthenticationArgs'
import { AuthenticationJwtPayloadType, AuthenticationResponseJwtPayloadType, encryptAndSign, OpenConnectionCallbackJwtPayloadType, verifyAndDecrypt } from 'shared'
const logger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.graphql.api.1_0.util.authenticateCommunity`)
export async function startOpenConnectionCallback(
args: OpenConnectionArgs,
comA: DbCommunity,
handshakeID: string,
publicKey: string,
api: string,
): Promise<void> {
logger.debug(`startOpenConnectionCallback() with:`, {
args,
comA: new CommunityLoggingView(comA),
const methodLogger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.graphql.api.1_0.util.authenticateCommunity.startOpenConnectionCallback`)
methodLogger.addContext('handshakeID', handshakeID)
methodLogger.debug(`Authentication: startOpenConnectionCallback() with:`, {
publicKey,
})
try {
const homeFedCom = await DbFedCommunity.findOneByOrFail({
const homeComB = await getHomeCommunity()
const homeFedComB = await DbFedCommunity.findOneByOrFail({
foreign: false,
apiVersion: api,
})
const comA = await DbCommunity.findOneByOrFail({ publicKey: Buffer.from(publicKey, 'hex') })
const fedComA = await DbFedCommunity.findOneByOrFail({
foreign: true,
apiVersion: api,
publicKey: comA.publicKey,
})
const oneTimeCode = randombytes_random()
const oneTimeCode = randombytes_random().toString()
// store oneTimeCode in requestedCom.community_uuid as authenticate-request-identifier
comA.communityUuid = oneTimeCode.toString()
comA.communityUuid = oneTimeCode
await DbCommunity.save(comA)
logger.debug(
methodLogger.debug(
`Authentication: stored oneTimeCode in requestedCom:`,
new CommunityLoggingView(comA),
)
@ -48,68 +52,94 @@ export async function startOpenConnectionCallback(
const client = AuthenticationClientFactory.getInstance(fedComA)
if (client instanceof V1_0_AuthenticationClient) {
const callbackArgs = new OpenConnectionCallbackArgs()
callbackArgs.oneTimeCode = oneTimeCode.toString()
// TODO encrypt callbackArgs.url with requestedCom.publicKey and sign it with homeCom.privateKey
callbackArgs.url = homeFedCom.endPoint.endsWith('/')
? homeFedCom.endPoint + homeFedCom.apiVersion
: homeFedCom.endPoint + '/' + homeFedCom.apiVersion
logger.debug(`Authentication: start openConnectionCallback with args:`, callbackArgs)
if (await client.openConnectionCallback(callbackArgs)) {
logger.debug('startOpenConnectionCallback() successful:', callbackArgs)
const url = homeFedComB.endPoint.endsWith('/')
? homeFedComB.endPoint + homeFedComB.apiVersion
: homeFedComB.endPoint + '/' + homeFedComB.apiVersion
const callbackArgs = new OpenConnectionCallbackJwtPayloadType(handshakeID, oneTimeCode, url)
methodLogger.debug(`Authentication: start openConnectionCallback with args:`, callbackArgs)
// encrypt callbackArgs with requestedCom.publicJwtKey and sign it with homeCom.privateJwtKey
const jwt = await encryptAndSign(callbackArgs, homeComB!.privateJwtKey!, comA.publicJwtKey!)
const args = new EncryptedTransferArgs()
args.publicKey = homeComB!.publicKey.toString('hex')
args.jwt = jwt
args.handshakeID = handshakeID
const result = await client.openConnectionCallback(args)
if (result) {
methodLogger.debug('startOpenConnectionCallback() successful:', jwt)
} else {
logger.error('startOpenConnectionCallback() failed:', callbackArgs)
methodLogger.error('startOpenConnectionCallback() failed:', jwt)
}
}
} catch (err) {
logger.error('error in startOpenConnectionCallback:', err)
methodLogger.error('error in startOpenConnectionCallback:', err)
}
methodLogger.removeContext('handshakeID')
}
export async function startAuthentication(
handshakeID: string,
oneTimeCode: string,
fedComB: DbFedCommunity,
): Promise<void> {
logger.debug(`startAuthentication()...`, {
const methodLogger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.graphql.api.1_0.util.authenticateCommunity.startAuthentication`)
methodLogger.addContext('handshakeID', handshakeID)
methodLogger.debug(`startAuthentication()...`, {
oneTimeCode,
fedComB: new FederatedCommunityLoggingView(fedComB),
})
try {
const homeCom = await DbCommunity.findOneByOrFail({ foreign: false })
const homeComA = await getHomeCommunity()
const comB = await DbCommunity.findOneByOrFail({
foreign: true,
publicKey: fedComB.publicKey,
})
if (!comB.publicJwtKey) {
throw new Error('Public JWT key still not exist for foreign community')
}
// TODO encrypt homeCom.uuid with homeCom.privateKey and sign it with callbackFedCom.publicKey
const client = AuthenticationClientFactory.getInstance(fedComB)
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(`invoke authenticate() with:`, authenticationArgs)
const fedComUuid = await client.authenticate(authenticationArgs)
logger.debug(`response of authenticate():`, fedComUuid)
if (fedComUuid !== null) {
logger.debug(
`received communityUUid for callbackFedCom:`,
fedComUuid,
const authenticationArgs = new AuthenticationJwtPayloadType(handshakeID, oneTimeCode, homeComA!.communityUuid!)
// encrypt authenticationArgs.uuid with fedComB.publicJwtKey and sign it with homeCom.privateJwtKey
const jwt = await encryptAndSign(authenticationArgs, homeComA!.privateJwtKey!, comB.publicJwtKey!)
const args = new EncryptedTransferArgs()
args.publicKey = homeComA!.publicKey.toString('hex')
args.jwt = jwt
args.handshakeID = handshakeID
methodLogger.debug(`invoke authenticate() with:`, args)
const responseJwt = await client.authenticate(args)
methodLogger.debug(`response of authenticate():`, responseJwt)
if (responseJwt !== null) {
const payload = await verifyAndDecrypt(handshakeID, responseJwt, homeComA!.privateJwtKey!, comB.publicJwtKey!) as AuthenticationResponseJwtPayloadType
methodLogger.debug(
`received payload from authenticate ComB:`,
payload,
new FederatedCommunityLoggingView(fedComB),
)
const callbackCom = await DbCommunity.findOneByOrFail({
foreign: true,
publicKey: fedComB.publicKey,
})
// TODO decrypt fedComUuid with callbackFedCom.publicKey
callbackCom.communityUuid = fedComUuid
callbackCom.authenticatedAt = new Date()
await DbCommunity.save(callbackCom)
logger.debug('Community Authentication successful:', new CommunityLoggingView(callbackCom))
if (payload.tokentype !== AuthenticationResponseJwtPayloadType.AUTHENTICATION_RESPONSE_TYPE) {
const errmsg = `Invalid tokentype in authenticate-response of community with publicKey` + comB.publicKey
methodLogger.error(errmsg)
methodLogger.removeContext('handshakeID')
throw new Error(errmsg)
}
if (!payload.uuid || !validateUUID(payload.uuid) || versionUUID(payload.uuid) !== 4) {
const errmsg = `Invalid uuid in authenticate-response of community with publicKey` + comB.publicKey
methodLogger.error(errmsg)
methodLogger.removeContext('handshakeID')
throw new Error(errmsg)
}
comB.communityUuid = payload.uuid
comB.authenticatedAt = new Date()
await DbCommunity.save(comB)
methodLogger.debug('Community Authentication successful:', new CommunityLoggingView(comB))
} else {
logger.error('Community Authentication failed:', authenticationArgs)
methodLogger.error('Community Authentication failed:', authenticationArgs)
}
}
} catch (err) {
logger.error('error in startAuthentication:', err)
methodLogger.error('error in startAuthentication:', err)
}
methodLogger.removeContext('handshakeID')
}

1
frontend/.env.test_e2e Normal file
View File

@ -0,0 +1 @@
GRAPHQL_URI=http://127.0.0.1:4000/graphql

View File

@ -21,7 +21,8 @@
"compile-scss": "node ./scripts/scss.mjs compile",
"watch-scss": "node ./scripts/scss.mjs watch",
"compile-scss-sass": "node ./scripts/scss.mjs compile sass",
"watch-scss-sass": "node ./scripts/scss.mjs watch sass"
"watch-scss-sass": "node ./scripts/scss.mjs watch sass",
"clear": "rm -rf node_modules && rm -rf build && rm -rf .turbo"
},
"dependencies": {
"@morev/vue-transitions": "^3.0.2",

View File

@ -8,11 +8,11 @@
<BImg class="sheet-img position-absolute d-block d-lg-none zindex1000" :src="sheet"></BImg>
<BCollapse id="nav-collapse" is-nav>
<BNavbarNav class="ms-auto me-4 d-none d-lg-flex" right>
<NavItem :to="register()" class="auth-navbar ms-lg-5">
<NavItem :to="routeWithParamsAndQuery('Register')" class="auth-navbar ms-lg-5">
{{ $t('signup') }}
</NavItem>
<span class="d-none d-lg-block py-1">{{ $t('|') }}</span>
<NavItem :to="login()" class="auth-navbar">
<NavItem :to="routeWithParamsAndQuery('Login')" class="auth-navbar">
{{ $t('signin') }}
</NavItem>
</BNavbarNav>
@ -25,7 +25,7 @@
import { useAuthLinks } from '@/composables/useAuthLinks'
import NavItem from '../Menu/NavItem.vue'
const { login, register } = useAuthLinks()
const { routeWithParamsAndQuery } = useAuthLinks()
const backgroundHeader = '/img/template/gradido_background_header.png'
const logo = '/img/brand/gradido-logo_200x59.png'

View File

@ -2,9 +2,13 @@
<div class="navbar-small">
<BNavbar class="navi">
<BNavbarNav>
<NavItem :to="register()" class="auth-navbar">{{ $t('signup') }}</NavItem>
<NavItem :to="routeWithParamsAndQuery('Register')" class="auth-navbar">
{{ $t('signup') }}
</NavItem>
<span class="mt-1">{{ $t('|') }}</span>
<NavItem :to="login()" class="auth-navbar">{{ $t('signin') }}</NavItem>
<NavItem :to="routeWithParamsAndQuery('Login')" class="auth-navbar">
{{ $t('signin') }}
</NavItem>
</BNavbarNav>
</BNavbar>
</div>
@ -14,7 +18,7 @@
import { useAuthLinks } from '@/composables/useAuthLinks'
import NavItem from '../Menu/NavItem.vue'
const { login, register } = useAuthLinks()
const { routeWithParamsAndQuery } = useAuthLinks()
</script>
<style scoped>
.navi {

View File

@ -10,13 +10,15 @@
<BRow>
<BCol sm="12" md="6">
<p>{{ $t('gdd_per_link.no-account') }}</p>
<BButton variant="primary" :to="register()">
<BButton variant="primary" :to="routeWithParamsAndQuery('Register')">
{{ $t('gdd_per_link.to-register') }}
</BButton>
</BCol>
<BCol sm="12" md="6" class="mt-4 mt-lg-0">
<p>{{ $t('gdd_per_link.has-account') }}</p>
<BButton variant="gradido" :to="login()">{{ $t('gdd_per_link.to-login') }}</BButton>
<BButton variant="gradido" :to="routeWithParamsAndQuery('Login')">
{{ $t('gdd_per_link.to-login') }}
</BButton>
</BCol>
</BRow>
</BCard>
@ -26,7 +28,7 @@
<script setup>
import { useAuthLinks } from '@/composables/useAuthLinks'
const { login, register } = useAuthLinks()
const { routeWithParamsAndQuery } = useAuthLinks()
defineProps({
linkData: { type: Object, required: true },
isContributionLink: { type: Boolean, default: false },

View File

@ -17,13 +17,21 @@
<BRow>
<BCol sm="12" md="6">
<p>{{ $t('gdd_per_link.no-account') }}</p>
<BButton variant="primary" :disabled="isForeignCommunitySelected" :to="register()">
<BButton
variant="primary"
:disabled="isForeignCommunitySelected"
:to="routeWithParamsAndQuery('Register')"
>
{{ $t('gdd_per_link.to-register') }}
</BButton>
</BCol>
<BCol sm="12" md="6" class="mt-4 mt-lg-0">
<p>{{ $t('gdd_per_link.has-account') }}</p>
<BButton variant="gradido" :disabled="isForeignCommunitySelected" :to="login()">
<BButton
variant="gradido"
:disabled="isForeignCommunitySelected"
:to="routeWithParamsAndQuery('Login')"
>
{{ $t('gdd_per_link.to-login') }}
</BButton>
</BCol>
@ -37,7 +45,7 @@ import { ref, computed } from 'vue'
import CONFIG from '@/config'
import { useAuthLinks } from '@/composables/useAuthLinks'
const { login, register } = useAuthLinks()
const { routeWithParamsAndQuery } = useAuthLinks()
const props = defineProps({
linkData: { type: Object, required: true },
redeemCode: { type: String, required: true },

View File

@ -3,7 +3,7 @@
<span v-if="isActive" class="nav-link active" aria-current="page">
<slot></slot>
</span>
<BNavItem v-else :to="href" class="auth-navbar"><slot></slot></BNavItem>
<BNavItem v-else v-bind="props" class="auth-navbar"><slot></slot></BNavItem>
</li>
</template>

View File

@ -1,19 +1,20 @@
import { useRoute } from 'vue-router'
export function useAuthLinks() {
const { params } = useRoute()
const login = () => {
if (params.code) return '/login/' + params.code
return '/login'
}
const register = () => {
if (params.code) return '/register/' + params.code
return '/register'
const route = useRoute()
/**
* Combine current route params and query with given params and query
* @param {string} name
* @param {{ params: {}, query: {} }} options
* @returns {{ name: string, params: {}, query: {} }} a vue3 routing object for :to
*/
const routeWithParamsAndQuery = (name, options = { params: {}, query: {} }) => {
return {
name,
params: { ...route.params, ...options.params },
query: { ...route.query, ...options.query },
}
}
return {
login,
register,
}
return { routeWithParamsAndQuery }
}

View File

@ -13,7 +13,10 @@
</BRow>
<BRow>
<BCol class="d-flex justify-content-end mb-4 mb-lg-0">
<router-link to="/forgot-password" data-test="forgot-password-link">
<router-link
:to="routeWithParamsAndQuery('ForgotPassword')"
data-test="forgot-password-link"
>
{{ $t('settings.password.forgot_pwd') }}
</router-link>
</BCol>
@ -39,7 +42,7 @@
</BRow>
<BRow>
<BCol class="mt-1 auth-navbar">
<BLink :to="register()">
<BLink :to="routeWithParamsAndQuery('Register')">
{{ $t('signup') }}
</BLink>
</BCol>
@ -82,7 +85,7 @@ const { mutate } = useMutation(login)
const { mutate: mutateHumhubAutoLogin } = useMutation(authenticateHumhubAutoLoginProject)
// const $loading = useLoading() // TODO needs to be updated but there is some sort of an issue that breaks the app.
const { toastError } = useAppToast()
const { register } = useAuthLinks()
const { routeWithParamsAndQuery } = useAuthLinks()
const form = ref({
email: '',
@ -123,7 +126,7 @@ const onSubmit = handleSubmit(async (values) => {
}
if (route.params.code) {
await router.push(`/redeem/${route.params.code}`)
await router.push(routeWithParamsAndQuery('Redeem'))
} else {
await router.push(store.state.redirectPath)
}
@ -131,12 +134,12 @@ const onSubmit = handleSubmit(async (values) => {
if (error.message.includes('User email not validated')) {
showPageMessage.value = true
errorSubtitle.value = t('message.activateEmail')
errorLinkTo.value = '/forgot-password'
errorLinkTo.value = routeWithParamsAndQuery('ForgotPassword')
toastError(t('error.no-account'))
} else if (error.message.includes('User has no password set yet')) {
showPageMessage.value = true
errorSubtitle.value = t('message.unsetPassword')
errorLinkTo.value = '/reset-password/login'
errorLinkTo.value = routeWithParamsAndQuery('ResetPassword')
toastError(t('error.no-account'))
} else if (error.message.includes('No user with this credentials')) {
toastError(t('error.no-user'))

View File

@ -77,7 +77,7 @@
</BRow>
<BRow>
<BCol class="mt-1 auth-navbar">
<BLink :to="login()">
<BLink :to="routeWithParamsAndQuery('Login')">
{{ $t('signin') }}
</BLink>
</BCol>
@ -105,7 +105,7 @@ import { useAuthLinks } from '@/composables/useAuthLinks'
import CONFIG from '@/config'
const { toastError } = useAppToast()
const { login } = useAuthLinks()
const { routeWithParamsAndQuery } = useAuthLinks()
const { mutate } = useMutation(createUser)

View File

@ -417,7 +417,10 @@ describe('ResetPassword', () => {
'...email was sent more than 23 hours and 10 minutes ago',
)
expect(message.props('buttonText')).toBe('settings.password.reset')
expect(message.props('linkTo')).toBe('/forgot-password/resetPassword')
expect(message.props('linkTo')).toMatchObject({
name: 'ForgotPassword',
params: { comingFrom: 'reset-password' },
})
expect(mockToastError).toHaveBeenCalledWith(
'...email was sent more than 23 hours and 10 minutes ago',
)
@ -435,7 +438,7 @@ describe('ResetPassword', () => {
expect(message.props('headline')).toBe('message.title')
expect(message.props('subtitle')).toBe('message.checkEmail')
expect(message.props('buttonText')).toBe('login')
expect(message.props('linkTo')).toBe('/login')
expect(message.props('linkTo')).toMatchObject({ name: 'Login' })
})
it('handles success response on /reset-password', async () => {
@ -454,7 +457,7 @@ describe('ResetPassword', () => {
expect(message.props('headline')).toBe('message.title')
expect(message.props('subtitle')).toBe('message.checkEmail')
expect(message.props('buttonText')).toBe('login')
expect(message.props('linkTo')).toBe('/login')
expect(message.props('linkTo')).toMatchObject({ name: 'Login' })
})
})

View File

@ -41,19 +41,22 @@ import InputPasswordConfirmation from '@/components/Inputs/InputPasswordConfirma
import Message from '@/components/Message/Message.vue'
import { useAppToast } from '@/composables/useToast'
import { useForm } from 'vee-validate'
import { useAuthLinks } from '@/composables/useAuthLinks'
const { routeWithParamsAndQuery } = useAuthLinks()
const textFields = {
reset: {
title: 'settings.password.change-password',
text: 'settings.password.reset-password.text',
button: 'settings.password.change-password',
linkTo: '/login',
linkTo: routeWithParamsAndQuery('Login'),
},
checkEmail: {
title: 'settings.password.set',
text: 'settings.password.set-password.text',
button: 'settings.password.set',
linkTo: '/login',
linkTo: routeWithParamsAndQuery('Login'),
},
}
@ -100,7 +103,7 @@ const onSubmit = async () => {
? t('message.checkEmail')
: t('message.reset')
messageButtonText.value = t('login')
messageButtonLinkTo.value = route.params.code ? `/login/${route.params.code}` : '/login'
messageButtonLinkTo.value = routeWithParamsAndQuery('Login')
} catch (error) {
const errorMessage = error.message.match(
/email was sent more than ([0-9]+ hours)?( and )?([0-9]+ minutes)? ago/,
@ -112,7 +115,9 @@ const onSubmit = async () => {
messageHeadline.value = t('message.errorTitle')
messageSubtitle.value = errorMessage
messageButtonText.value = t('settings.password.reset')
messageButtonLinkTo.value = '/forgot-password/resetPassword'
messageButtonLinkTo.value = routeWithParamsAndQuery('ForgotPassword', {
params: { comingFrom: 'reset-password' },
})
toastError(errorMessage)
}
}
@ -124,7 +129,9 @@ const checkOptInCode = async () => {
})
} catch (error) {
toastError(error.message)
await router.push('/forgot-password/resetPassword')
await router.push(
routeWithParamsAndQuery('ForgotPassword', { params: { comingFrom: 'reset-password' } }),
)
}
}

View File

@ -114,18 +114,22 @@ const routes = [
},
},
{
name: 'Login',
path: '/login/:code?',
component: () => import('@/pages/Login'),
},
{
name: 'Register',
path: '/register/:code?',
component: () => import('@/pages/Register'),
},
{
name: 'ForgotPassword',
path: '/forgot-password',
component: () => import('@/pages/ForgotPassword'),
},
{
name: 'ForgotPasswordComingFrom',
path: '/forgot-password/:comingFrom',
component: () => import('@/pages/ForgotPassword'),
},
@ -138,14 +142,17 @@ const routes = [
// component: () => import('@/pages/SelectCommunity'),
// },
{
name: 'ResetPassword',
path: '/reset-password/:optin',
component: () => import('@/pages/ResetPassword'),
},
{
name: 'CheckEmail',
path: '/checkEmail/:optin/:code?',
component: () => import('@/pages/ResetPassword'),
},
{
name: 'Redeem',
path: '/redeem/:code',
component: () => import('@/pages/TransactionLink'),
},

View File

@ -25,11 +25,13 @@
"docker": "cross-env BUILD_COMMIT=$(git rev-parse HEAD) docker compose -f docker-compose.yml up",
"docker:rebuild": "cross-env BUILD_COMMIT=$(git rev-parse HEAD) docker compose -f docker-compose.yml build",
"docker_dev": "cross-env BUILD_COMMIT=$(git rev-parse HEAD) docker compose up",
"docker_dev:rebuild": "cross-env BUILD_COMMIT=$(git rev-parse HEAD) docker compose build"
"docker_dev:rebuild": "cross-env BUILD_COMMIT=$(git rev-parse HEAD) docker compose build",
"clear": "rm -rf .turbo && turbo clear"
},
"dependencies": {
"auto-changelog": "^2.4.0",
"cross-env": "^7.0.3",
"jose": "^4.14.4",
"turbo": "^2.5.0",
"uuid": "^8.3.2"
},

View File

@ -21,7 +21,8 @@
"test:debug": "bun test --inspect-brk",
"typecheck": "tsc --noEmit",
"lint": "biome check --error-on-warnings .",
"lint:fix": "biome check --error-on-warnings . --write"
"lint:fix": "biome check --error-on-warnings . --write",
"clear": "rm -rf node_modules && rm -rf build && rm -rf .turbo"
},
"devDependencies": {
"@biomejs/biome": "2.0.0",
@ -33,6 +34,7 @@
"dependencies": {
"decimal.js-light": "^2.5.1",
"esbuild": "^0.25.2",
"jose": "^4.14.4",
"log4js": "^6.9.1",
"zod": "^3.25.61"
},

View File

@ -1,2 +1,3 @@
export const DECAY_START_TIME = new Date('2021-05-13T17:46:31Z')
export const LOG4JS_BASE_CATEGORY_NAME = 'shared'
export const LOG4JS_BASE_CATEGORY_NAME = 'shared'
export const REDEEM_JWT_TOKEN_EXPIRATION = '10m'

View File

@ -1,3 +1,12 @@
export * from './schema'
export * from './enum'
export * from './logic/decay'
export * from './jwt/JWT'
export * from './jwt/payloadtypes/AuthenticationJwtPayloadType'
export * from './jwt/payloadtypes/AuthenticationResponseJwtPayloadType'
export * from './jwt/payloadtypes/DisburseJwtPayloadType'
export * from './jwt/payloadtypes/EncryptedJWEJwtPayloadType'
export * from './jwt/payloadtypes/JwtPayloadType'
export * from './jwt/payloadtypes/OpenConnectionJwtPayloadType'
export * from './jwt/payloadtypes/OpenConnectionCallbackJwtPayloadType'
export * from './jwt/payloadtypes/RedeemJwtPayloadType'

167
shared/src/jwt/JWT.test.ts Normal file
View File

@ -0,0 +1,167 @@
// import { testEnvironment } from '@test/helpers'
// import { logger } from '@test/testSetup'
import { createKeyPair, decode, decrypt, encode, encrypt, encryptAndSign, verify, verifyAndDecrypt } from './JWT'
import { EncryptedJWEJwtPayloadType } from './payloadtypes/EncryptedJWEJwtPayloadType'
import { OpenConnectionJwtPayloadType } from './payloadtypes/OpenConnectionJwtPayloadType'
// let con: DataSource
// let testEnv: {
// mutate: ApolloServerTestClient['mutate']
// query: ApolloServerTestClient['query']
// con: DataSource
// }
let keypairComA: { publicKey: string; privateKey: string }
let keypairComB: { publicKey: string; privateKey: string }
beforeAll(async () => {
// testEnv = await testEnvironment(logger)
// con = testEnv.con
// await cleanDB()
keypairComA = await createKeyPair()
keypairComB = await createKeyPair()
})
afterAll(async () => {
// await cleanDB()
// await con.destroy()
})
describe('test JWS creation and verification', () => {
let jwsComA: string
let jwsComB: string
beforeEach(async () => {
jest.clearAllMocks()
jwsComA = await encode(new OpenConnectionJwtPayloadType('handshakeID', 'http://localhost:5001/api/'), keypairComA.privateKey)
jwsComB = await encode(new OpenConnectionJwtPayloadType('handshakeID', 'http://localhost:5002/api/'), keypairComB.privateKey)
})
it('decode jwsComA', async () => {
const decodedJwsComA = await decode(jwsComA)
expect(decodedJwsComA).toEqual({
expiration: '10m',
handshakeID: 'handshakeID',
tokentype: OpenConnectionJwtPayloadType.OPEN_CONNECTION_TYPE,
url: 'http://localhost:5001/api/',
})
})
it('decode jwsComB', async () => {
const decodedJwsComB = await decode(jwsComB)
expect(decodedJwsComB).toEqual({
expiration: '10m',
handshakeID: 'handshakeID',
tokentype: OpenConnectionJwtPayloadType.OPEN_CONNECTION_TYPE,
url: 'http://localhost:5002/api/',
})
})
it('verify jwsComA', async () => {
const verifiedJwsComA = await verify('handshakeID', jwsComA, keypairComA.publicKey)
expect(verifiedJwsComA).toEqual(expect.objectContaining({
payload: expect.objectContaining({
tokentype: OpenConnectionJwtPayloadType.OPEN_CONNECTION_TYPE,
url: 'http://localhost:5001/api/',
})
}))
})
it('verify jwsComB', async () => {
const verifiedJwsComB = await verify('handshakeID', jwsComB, keypairComB.publicKey)
expect(verifiedJwsComB).toEqual(expect.objectContaining({
payload: expect.objectContaining({
tokentype: OpenConnectionJwtPayloadType.OPEN_CONNECTION_TYPE,
url: 'http://localhost:5002/api/',
})
}))
})
})
describe('test JWE encryption and decryption', () => {
let jweComA: string
let jweComB: string
beforeEach(async () => {
jest.clearAllMocks()
jweComA = await encrypt(new OpenConnectionJwtPayloadType('handshakeID', 'http://localhost:5001/api/'), keypairComB.publicKey)
jweComB = await encrypt(new OpenConnectionJwtPayloadType('handshakeID', 'http://localhost:5002/api/'), keypairComA.publicKey)
})
it('decrypt jweComA', async () => {
const decryptedAJwT = await decrypt('handshakeID', jweComA, keypairComB.privateKey)
expect(JSON.parse(decryptedAJwT)).toEqual(expect.objectContaining({
tokentype: OpenConnectionJwtPayloadType.OPEN_CONNECTION_TYPE,
url: 'http://localhost:5001/api/',
handshakeID: 'handshakeID',
}))
})
it('decrypt jweComB', async () => {
const decryptedBJwT = await decrypt('handshakeID', jweComB, keypairComA.privateKey)
expect(JSON.parse(decryptedBJwT)).toEqual(expect.objectContaining({
tokentype: OpenConnectionJwtPayloadType.OPEN_CONNECTION_TYPE,
url: 'http://localhost:5002/api/',
handshakeID: 'handshakeID',
}))
})
})
describe('test encrypted and signed JWT', () => {
let jweComA: string
let jwsComA: string
let jweComB: string
let jwsComB: string
beforeEach(async () => {
jest.clearAllMocks()
jweComA = await encrypt(new OpenConnectionJwtPayloadType('handshakeID', 'http://localhost:5001/api/'), keypairComB.publicKey)
jwsComA = await encode(new EncryptedJWEJwtPayloadType('handshakeID', jweComA), keypairComA.privateKey)
jweComB = await encrypt(new OpenConnectionJwtPayloadType('handshakeID', 'http://localhost:5002/api/'), keypairComA.publicKey)
jwsComB = await encode(new EncryptedJWEJwtPayloadType('handshakeID', jweComB), keypairComB.privateKey)
})
it('verify jwsComA', async () => {
const verifiedJwsComA = await verify('handshakeID', jwsComA, keypairComA.publicKey)
expect(verifiedJwsComA).toEqual(expect.objectContaining({
payload: expect.objectContaining({
jwe: jweComA,
tokentype: EncryptedJWEJwtPayloadType.ENCRYPTED_JWE_TYPE,
handshakeID: 'handshakeID',
})
}))
})
it('verify jwsComB', async () => {
const verifiedJwsComB = await verify('handshakeID', jwsComB, keypairComB.publicKey)
expect(verifiedJwsComB).toEqual(expect.objectContaining({
payload: expect.objectContaining({
jwe: jweComB,
tokentype: EncryptedJWEJwtPayloadType.ENCRYPTED_JWE_TYPE,
handshakeID: 'handshakeID',
})
}))
})
it('decrypt jweComA', async () => {
expect(JSON.parse(await decrypt('handshakeID', jweComA, keypairComB.privateKey))).toEqual(expect.objectContaining({
tokentype: OpenConnectionJwtPayloadType.OPEN_CONNECTION_TYPE,
url: 'http://localhost:5001/api/',
handshakeID: 'handshakeID',
}))
})
it('decrypt jweComB', async () => {
expect(JSON.parse(await decrypt('handshakeID', jweComB, keypairComA.privateKey))).toEqual(expect.objectContaining({
tokentype: OpenConnectionJwtPayloadType.OPEN_CONNECTION_TYPE,
url: 'http://localhost:5002/api/',
handshakeID: 'handshakeID',
}))
})
})
describe('test encryptAndSign and verifyAndDecrypt', () => {
let jwtComA: string
beforeEach(async () => {
jest.clearAllMocks()
jwtComA = await encryptAndSign(new OpenConnectionJwtPayloadType('handshakeID', 'http://localhost:5001/api/'), keypairComA.privateKey, keypairComB.publicKey)
})
it('verifyAndDecrypt jwtComA', async () => {
const verifiedAndDecryptedPayload = await verifyAndDecrypt('handshakeID', jwtComA, keypairComB.privateKey, keypairComA.publicKey)
expect(verifiedAndDecryptedPayload).toEqual(expect.objectContaining({
tokentype: OpenConnectionJwtPayloadType.OPEN_CONNECTION_TYPE,
url: 'http://localhost:5001/api/',
handshakeID: 'handshakeID',
}))
})
})

176
shared/src/jwt/JWT.ts Normal file
View File

@ -0,0 +1,176 @@
import { generateKeyPair, exportSPKI, exportPKCS8, SignJWT, decodeJwt, importPKCS8, importSPKI, jwtVerify, CompactEncrypt, compactDecrypt } from 'jose'
import { LOG4JS_BASE_CATEGORY_NAME } from '../const'
import { getLogger } from 'log4js'
const logger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.auth.jwt.JWT`)
import { JwtPayloadType } from './payloadtypes/JwtPayloadType'
import { EncryptedJWEJwtPayloadType } from './payloadtypes/EncryptedJWEJwtPayloadType'
export const createKeyPair = async (): Promise<{ publicKey: string; privateKey: string }> => {
// Generate key pair using jose library
const keyPair = await generateKeyPair('RS256', {
modulusLength: 2048, // recommended key size
extractable: true,
});
logger.debug(`Federation: writeJwtKeyPairInHomeCommunity generated keypair...`);
// Convert keys to PEM format for storage in database
const publicKeyPem = await exportSPKI(keyPair.publicKey);
const privateKeyPem = await exportPKCS8(keyPair.privateKey);
return { publicKey: publicKeyPem, privateKey: privateKeyPem };
}
export const verify = async (handshakeID: string, token: string, publicKey: string): Promise<JwtPayloadType | null> => {
const methodLogger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.auth.jwt.JWT.verify`)
methodLogger.addContext('handshakeID', handshakeID)
if (!token) {
methodLogger.error('verify... token is empty')
throw new Error('401 Unauthorized')
}
methodLogger.debug('verify... token, publicKey=', token, publicKey)
try {
const importedKey = await importSPKI(publicKey, 'RS256')
// Convert the key to JWK format if needed
const secret = typeof importedKey === 'string'
? JSON.parse(importedKey)
: importedKey;
// const secret = new TextEncoder().encode(publicKey)
const { payload } = await jwtVerify(token, secret, {
issuer: JwtPayloadType.ISSUER,
audience: JwtPayloadType.AUDIENCE,
})
payload.handshakeID = handshakeID
methodLogger.debug('verify after jwtVerify... payload=', payload)
methodLogger.removeContext('handshakeID')
return payload as JwtPayloadType
} catch (err) {
methodLogger.error('verify after jwtVerify... error=', err)
methodLogger.removeContext('handshakeID')
return null
}
}
export const encode = async (payload: JwtPayloadType, privatekey: string): Promise<string> => {
const methodLogger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.auth.jwt.JWT.encode`)
methodLogger.addContext('handshakeID', payload.handshakeID)
methodLogger.debug('encode... payload=', payload)
methodLogger.debug('encode... privatekey=', privatekey.substring(0, 20))
try {
const importedKey = await importPKCS8(privatekey, 'RS256')
const secret = typeof importedKey === 'string'
? JSON.parse(importedKey)
: importedKey;
// const secret = new TextEncoder().encode(privatekey)
const token = await new SignJWT({ payload, 'urn:gradido:claim': true })
.setProtectedHeader({
alg: 'RS256',
})
.setIssuedAt()
.setIssuer(JwtPayloadType.ISSUER)
.setAudience(JwtPayloadType.AUDIENCE)
.setExpirationTime(payload.expiration)
.sign(secret)
methodLogger.debug('encode... token=', token)
methodLogger.removeContext('handshakeID')
return token
} catch (e) {
methodLogger.error('Failed to sign JWT:', e)
methodLogger.removeContext('handshakeID')
throw e
}
}
export const verifyJwtType = async (handshakeID: string, token: string, publicKey: string): Promise<string> => {
const payload = await verify(handshakeID, token, publicKey)
return payload ? payload.tokentype : 'unknown token type'
}
export const decode = (token: string): JwtPayloadType => {
const { payload } = decodeJwt(token)
return payload as JwtPayloadType
}
export const encrypt = async (payload: JwtPayloadType, publicKey: string): Promise<string> => {
const methodLogger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.auth.jwt.JWT.encrypt`)
methodLogger.addContext('handshakeID', payload.handshakeID)
methodLogger.debug('encrypt... payload=', payload)
methodLogger.debug('encrypt... publicKey=', publicKey)
try {
const encryptKey = await importSPKI(publicKey, 'RSA-OAEP-256')
// Convert the key to JWK format if needed
const recipientKey = typeof encryptKey === 'string'
? JSON.parse(encryptKey)
: encryptKey;
const jwe = await new CompactEncrypt(
new TextEncoder().encode(JSON.stringify(payload)),
)
.setProtectedHeader({ alg: 'RSA-OAEP-256', enc: 'A256GCM' })
.encrypt(recipientKey)
methodLogger.debug('encrypt... jwe=', jwe)
methodLogger.removeContext('handshakeID')
return jwe.toString()
} catch (e) {
methodLogger.error('Failed to encrypt JWT:', e)
methodLogger.removeContext('handshakeID')
throw e
}
}
export const decrypt = async(handshakeID: string, jwe: string, privateKey: string): Promise<string> => {
const methodLogger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.auth.jwt.JWT.decrypt`)
methodLogger.addContext('handshakeID', handshakeID)
methodLogger.debug('decrypt... jwe=', jwe)
methodLogger.debug('decrypt... privateKey=', privateKey.substring(0, 10))
try {
const decryptKey = await importPKCS8(privateKey, 'RSA-OAEP-256')
const { plaintext, protectedHeader } =
await compactDecrypt(jwe, decryptKey)
methodLogger.debug('decrypt... plaintext=', plaintext)
methodLogger.debug('decrypt... protectedHeader=', protectedHeader)
methodLogger.removeContext('handshakeID')
return new TextDecoder().decode(plaintext)
} catch (e) {
methodLogger.error('Failed to decrypt JWT:', e)
methodLogger.removeContext('handshakeID')
throw e
}
}
export const encryptAndSign = async (payload: JwtPayloadType, privateKey: string, publicKey: string): Promise<string> => {
const methodLogger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.auth.jwt.JWT.encryptAndSign`)
methodLogger.addContext('handshakeID', payload.handshakeID)
const jwe = await encrypt(payload, publicKey)
methodLogger.debug('encryptAndSign... jwe=', jwe)
const jws = await encode(new EncryptedJWEJwtPayloadType(payload.handshakeID, jwe), privateKey)
methodLogger.debug('encryptAndSign... jws=', jws)
methodLogger.removeContext('handshakeID')
return jws
}
export const verifyAndDecrypt = async (handshakeID: string, token: string, privateKey: string, publicKey: string): Promise<JwtPayloadType | null> => {
const methodLogger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.auth.jwt.JWT.verifyAndDecrypt`)
methodLogger.addContext('handshakeID', handshakeID)
const jweVerifyResult = await verify(handshakeID, token, publicKey)
if (!jweVerifyResult) {
return null
}
const jwePayload = jweVerifyResult.payload as EncryptedJWEJwtPayloadType
methodLogger.debug('verifyAndDecrypt... jwePayload=', jwePayload)
if (!jwePayload) {
return null
}
const jwePayloadType = jwePayload.tokentype
if (jwePayloadType !== EncryptedJWEJwtPayloadType.ENCRYPTED_JWE_TYPE) {
return null
}
const jwe = jwePayload.jwe
methodLogger.debug('verifyAndDecrypt... jwe=', jwe)
const payload = await decrypt(handshakeID, jwe as string, privateKey)
methodLogger.debug('verifyAndDecrypt... payload=', payload)
methodLogger.removeContext('handshakeID')
return JSON.parse(payload) as JwtPayloadType
}

View File

@ -0,0 +1,19 @@
import { JwtPayloadType } from './JwtPayloadType'
export class AuthenticationJwtPayloadType extends JwtPayloadType {
static AUTHENTICATION_TYPE = 'authentication'
oneTimeCode: string
uuid: string
constructor(
handshakeID: string,
oneTimeCode: string,
uuid: string,
) {
super(handshakeID)
this.tokentype = AuthenticationJwtPayloadType.AUTHENTICATION_TYPE
this.oneTimeCode = oneTimeCode
this.uuid = uuid
}
}

View File

@ -0,0 +1,16 @@
import { JwtPayloadType } from './JwtPayloadType'
export class AuthenticationResponseJwtPayloadType extends JwtPayloadType {
static AUTHENTICATION_RESPONSE_TYPE = 'authenticationResponse'
uuid: string
constructor(
handshakeID: string,
uuid: string,
) {
super(handshakeID)
this.tokentype = AuthenticationResponseJwtPayloadType.AUTHENTICATION_RESPONSE_TYPE
this.uuid = uuid
}
}

View File

@ -30,7 +30,7 @@ export class DisburseJwtPayloadType extends JwtPayloadType {
recipientAlias: string,
) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
super()
super('handshakeID')
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
this.tokentype = DisburseJwtPayloadType.DISBURSE_ACTIVATION_TYPE
this.sendercommunityuuid = senderCommunityUuid

View File

@ -0,0 +1,18 @@
import { JwtPayloadType } from './JwtPayloadType'
export class EncryptedJWEJwtPayloadType extends JwtPayloadType {
static ENCRYPTED_JWE_TYPE = 'encrypted-jwe'
jwe: string
constructor(
handshakeID: string,
jwe: string,
) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
super(handshakeID)
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
this.tokentype = EncryptedJWEJwtPayloadType.ENCRYPTED_JWE_TYPE
this.jwe = jwe
}
}

View File

@ -1,8 +1,11 @@
import { JWTPayload } from 'jose'
import { CONFIG } from '@/config'
import { REDEEM_JWT_TOKEN_EXPIRATION } from '../../const'
export class JwtPayloadType implements JWTPayload {
static ISSUER = 'urn:gradido:issuer'
static AUDIENCE = 'urn:gradido:audience'
iat?: number | undefined
exp?: number | undefined
nbf?: number | undefined
@ -12,10 +15,12 @@ export class JwtPayloadType implements JWTPayload {
iss?: string | undefined;
[propName: string]: unknown
handshakeID: string // used as logger context during authentication handshake between comA and comB
tokentype: string
expiration: string // in minutes (format: 10m for ten minutes)
constructor() {
constructor(handshakeID: string) {
this.tokentype = 'unknown jwt type'
this.expiration = CONFIG.REDEEM_JWT_TOKEN_EXPIRATION || '10m'
this.expiration = REDEEM_JWT_TOKEN_EXPIRATION || '10m'
this.handshakeID = handshakeID
}
}

View File

@ -0,0 +1,21 @@
import { JwtPayloadType } from './JwtPayloadType'
export class OpenConnectionCallbackJwtPayloadType extends JwtPayloadType {
static OPEN_CONNECTION_CALLBACK_TYPE = 'open-connection-callback'
oneTimeCode: string
url: string
constructor(
handshakeID: string,
oneTimeCode: string,
url: string,
) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
super(handshakeID)
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
this.tokentype = OpenConnectionCallbackJwtPayloadType.OPEN_CONNECTION_CALLBACK_TYPE
this.oneTimeCode = oneTimeCode
this.url = url
}
}

View File

@ -0,0 +1,18 @@
import { JwtPayloadType } from './JwtPayloadType'
export class OpenConnectionJwtPayloadType extends JwtPayloadType {
static OPEN_CONNECTION_TYPE = 'open-connection'
url: string
constructor(
handshakeID: string,
url: string,
) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
super(handshakeID)
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
this.tokentype = OpenConnectionJwtPayloadType.OPEN_CONNECTION_TYPE
this.url = url
}
}

View File

@ -1,4 +1,3 @@
// import { JWTPayload } from 'jose'
import { JwtPayloadType } from './JwtPayloadType'
export class RedeemJwtPayloadType extends JwtPayloadType {
@ -22,7 +21,7 @@ export class RedeemJwtPayloadType extends JwtPayloadType {
validUntil: string,
) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
super()
super('handshakeID')
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
this.tokentype = RedeemJwtPayloadType.REDEEM_ACTIVATION_TYPE
this.sendercommunityuuid = senderCom

View File

@ -24,6 +24,9 @@
"dependsOn": ["build"],
"persistent": true,
"cache": false
},
"clear": {
"cache": false
}
}
}

5961
yarn.lock

File diff suppressed because it is too large Load Diff