From c75e43eafe8e0c8c6117c8fd4e8210247f0ab54e Mon Sep 17 00:00:00 2001 From: clauspeterhuebner Date: Wed, 25 Jun 2025 16:13:23 +0200 Subject: [PATCH] JWT signing and encryption methods and tests --- backend/src/auth/jwt/JWT.test.ts | 161 ++++++++++++++++++ backend/src/auth/jwt/JWT.ts | 101 ++++++++--- .../EncryptedJWEJwtPayloadType.ts | 18 ++ 3 files changed, 258 insertions(+), 22 deletions(-) create mode 100644 backend/src/auth/jwt/JWT.test.ts create mode 100644 backend/src/auth/jwt/payloadtypes/EncryptedJWEJwtPayloadType.ts diff --git a/backend/src/auth/jwt/JWT.test.ts b/backend/src/auth/jwt/JWT.test.ts new file mode 100644 index 000000000..45d812ee0 --- /dev/null +++ b/backend/src/auth/jwt/JWT.test.ts @@ -0,0 +1,161 @@ +import { ApolloServerTestClient } from 'apollo-server-testing' +import { FederatedCommunity as DbFederatedCommunity } from 'database' +import { GraphQLClient } from 'graphql-request' +import { Response } from 'graphql-request/dist/types' +import { DataSource } from 'typeorm' + +import { cleanDB, testEnvironment } from '@test/helpers' +import { logger } from '@test/testSetup' + +import { encode, decode, verify, encrypt, decrypt, createKeyPair } from './JWT' +import { OpenConnectionJwtPayloadType } from './payloadtypes/OpenConnectionJwtPayloadType' +import { EncryptedJWEJwtPayloadType } from './payloadtypes/EncryptedJWEJwtPayloadType' + +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('http://localhost:5001/api/'), keypairComA.privateKey) + console.log('jwsComA=', jwsComA) + jwsComB = await encode(new OpenConnectionJwtPayloadType('http://localhost:5002/api/'), keypairComB.privateKey) + console.log('jwsComB=', jwsComB) + }) + it('decode jwsComA', async () => { + const decodedJwsComA = await decode(jwsComA) + console.log('decodedJwsComA=', decodedJwsComA) + expect(decodedJwsComA).toEqual({ + expiration: '10m', + tokentype: OpenConnectionJwtPayloadType.OPEN_CONNECTION_TYPE, + url: 'http://localhost:5001/api/', + }) + }) + it('decode jwsComB', async () => { + const decodedJwsComB = await decode(jwsComB) + console.log('decodedJwsComB=', decodedJwsComB) + expect(decodedJwsComB).toEqual({ + expiration: '10m', + tokentype: OpenConnectionJwtPayloadType.OPEN_CONNECTION_TYPE, + url: 'http://localhost:5002/api/', + }) + }) + it('verify jwsComA', async () => { + const verifiedJwsComA = await verify(jwsComA, keypairComA.publicKey) + console.log('verify jwsComA=', verifiedJwsComA) + 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(jwsComB, keypairComB.publicKey) + console.log('verify jwsComB=', verifiedJwsComB) + 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('http://localhost:5001/api/'), keypairComB.publicKey) + console.log('jweComA=', jweComA) + jweComB = await encrypt(new OpenConnectionJwtPayloadType('http://localhost:5002/api/'), keypairComA.publicKey) + console.log('jweComB=', jweComB) + }) + it('decrypt jweComA', async () => { + const decryptedAJwT = await decrypt(jweComA, keypairComB.privateKey) + console.log('decryptedAJwT=', decryptedAJwT) + expect(JSON.parse(decryptedAJwT)).toEqual(expect.objectContaining({ + tokentype: OpenConnectionJwtPayloadType.OPEN_CONNECTION_TYPE, + url: 'http://localhost:5001/api/', + })) + }) + it('decrypt jweComB', async () => { + const decryptedBJwT = await decrypt(jweComB, keypairComA.privateKey) + console.log('decryptedBJwT=', decryptedBJwT) + expect(JSON.parse(decryptedBJwT)).toEqual(expect.objectContaining({ + tokentype: OpenConnectionJwtPayloadType.OPEN_CONNECTION_TYPE, + url: 'http://localhost:5002/api/', + })) + }) +}) + +describe('test encrypted and signed JWT', () => { + let jweComA: string + let jwsComA: string + let jwtComA: string + let jweComB: string + let jwsComB: string + let jwtComB: string + beforeEach(async () => { + jest.clearAllMocks() + jweComA = await encrypt(new OpenConnectionJwtPayloadType('http://localhost:5001/api/'), keypairComB.publicKey) + console.log('jweComA=', jweComA) + jwsComA = await encode(new EncryptedJWEJwtPayloadType(jweComA), keypairComA.privateKey) + console.log('jwsComA=', jwsComA) + jweComB = await encrypt(new OpenConnectionJwtPayloadType('http://localhost:5002/api/'), keypairComA.publicKey) + console.log('jweComB=', jweComB) + jwsComB = await encode(new EncryptedJWEJwtPayloadType(jweComB), keypairComB.privateKey) + console.log('jwsComB=', jwsComB) + }) + it('verify jwsComA', async () => { + expect(await verify(jwsComA, keypairComA.publicKey)).toEqual(expect.objectContaining({ + payload: expect.objectContaining({ + jwe: jweComA, + tokentype: EncryptedJWEJwtPayloadType.ENCRYPTED_JWE_TYPE, + }) + })) + }) + it('verify jwsComB', async () => { + expect(await verify(jwsComB, keypairComB.publicKey)).toEqual(expect.objectContaining({ + payload: expect.objectContaining({ + jwe: jweComB, + tokentype: EncryptedJWEJwtPayloadType.ENCRYPTED_JWE_TYPE, + }) + })) + }) + it('decrypt jweComA', async () => { + expect(JSON.parse(await decrypt(jweComA, keypairComB.privateKey))).toEqual(expect.objectContaining({ + tokentype: OpenConnectionJwtPayloadType.OPEN_CONNECTION_TYPE, + url: 'http://localhost:5001/api/', + })) + }) + it('decrypt jweComB', async () => { + expect(JSON.parse(await decrypt(jweComB, keypairComA.privateKey))).toEqual(expect.objectContaining({ + tokentype: OpenConnectionJwtPayloadType.OPEN_CONNECTION_TYPE, + url: 'http://localhost:5002/api/', + })) + }) +}) \ No newline at end of file diff --git a/backend/src/auth/jwt/JWT.ts b/backend/src/auth/jwt/JWT.ts index 6f1fa34c2..b0687b4b9 100644 --- a/backend/src/auth/jwt/JWT.ts +++ b/backend/src/auth/jwt/JWT.ts @@ -1,16 +1,28 @@ -import { GeneralEncrypt, KeyLike, SignJWT, decodeJwt, jwtVerify } from 'jose' +import { generateKeyPair, exportSPKI, exportPKCS8, KeyLike, SignJWT, decodeJwt, generalDecrypt, importPKCS8, importSPKI, jwtVerify, CompactEncrypt, compactDecrypt } from 'jose' +import { GeneralJWE } from 'jose/dist/types/types' import { LogError } from '@/server/LogError' import { backendLogger as logger } from '@/server/logger' import { JwtPayloadType } from './payloadtypes/JwtPayloadType' -export const verify = async (token: string, signkey: string): Promise => { +export const createKeyPair = async (): Promise<{ publicKey: string; privateKey: string }> => { + // Generate key pair using jose library + const keyPair = await generateKeyPair('RS256'); + logger.debug(`Federation: writeJwtKeyPairInHomeCommunity generated keypair=`, 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 (token: string, publicKey: string): Promise => { if (!token) { throw new LogError('401 Unauthorized') } - logger.info('JWT.verify... token, signkey=', token, signkey) + logger.info('JWT.verify... token, publicKey=', token, publicKey) try { /* @@ -24,7 +36,12 @@ export const verify = async (token: string, signkey: string): Promise => { +export const encode = async (payload: JwtPayloadType, privatekey: string): Promise => { logger.info('JWT.encode... payload=', payload) - logger.info('JWT.encode... signkey=', signkey) + logger.info('JWT.encode... privatekey=', privatekey) try { - const secret = new TextEncoder().encode(signkey) + 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: 'HS256', + alg: 'RS256', }) .setIssuedAt() .setIssuer('urn:gradido:issuer') @@ -58,8 +80,8 @@ export const encode = async (payload: JwtPayloadType, signkey: string): Promise< } } -export const verifyJwtType = async (token: string, signkey: string): Promise => { - const payload = await verify(token, signkey) +export const verifyJwtType = async (token: string, publicKey: string): Promise => { + const payload = await verify(token, publicKey) return payload ? payload.tokentype : 'unknown token type' } @@ -68,26 +90,33 @@ export const decode = (token: string): JwtPayloadType => { return payload as JwtPayloadType } -export const encrypt = async(payload: JwtPayloadType, encryptkey: KeyLike): Promise => { +export const encrypt = async (payload: JwtPayloadType, publicKey: string): Promise => { logger.info('JWT.encrypt... payload=', payload) - logger.info('JWT.encrypt... encryptkey=', encryptkey) + logger.info('JWT.encrypt... publicKey=', publicKey) try { + const encryptKey = await importSPKI(publicKey, 'RS256') // Convert the key to JWK format if needed - const recipientKey = typeof encryptkey === 'string' - ? JSON.parse(encryptkey) - : encryptkey; + const recipientKey = typeof encryptKey === 'string' + ? JSON.parse(encryptKey) + : encryptKey; - const jwe = await new GeneralEncrypt( + const jwe = await new CompactEncrypt( new TextEncoder().encode(JSON.stringify(payload)), ) - .setProtectedHeader({ enc: 'A256GCM' }) - .addRecipient(recipientKey) - .setUnprotectedHeader({ alg: 'ECDH-ES+A256KW' }) - .encrypt() + .setProtectedHeader({ alg: 'RSA-OAEP-256', enc: 'A256GCM' }) + .encrypt(recipientKey) /* + const jwe = await new EncryptJWT({ payload, 'urn:gradido:claim': true }) + .setProtectedHeader({ alg: 'RSA-OAEP-256', enc: 'A256GCM' }) + .setIssuedAt() + .setIssuer('urn:gradido:issuer') + .setAudience('urn:gradido:audience') + .setExpirationTime('5m') + .encrypt(recipientKey); + const token = await new EncryptJWT({ payload, 'urn:gradido:claim': true }) .setProtectedHeader({ - alg: 'HS256', + alg: 'RS256', enc: 'A256GCM', }) .setIssuedAt() @@ -102,4 +131,32 @@ export const encrypt = async(payload: JwtPayloadType, encryptkey: KeyLike): Prom logger.error('Failed to encrypt JWT:', e) throw e } -} \ No newline at end of file +} + +export const decrypt = async(jwe: string, privateKey: string): Promise => { + logger.info('JWT.decrypt... jwe=', jwe) + logger.info('JWT.decrypt... privateKey=', privateKey) + try { + const decryptKey = await importPKCS8(privateKey, 'RS256') + const { plaintext, protectedHeader } = + await compactDecrypt(jwe, decryptKey) + logger.info('JWT.decrypt... plaintext=', plaintext) + logger.info('JWT.decrypt... protectedHeader=', protectedHeader) + return plaintext.toString() + /* + const generalJwe = await GeneralJWE.parse(jwe) + const jws = await generalDecrypt(generalJwe, privateKey, { alg: 'ECDH-ES+A256KW', enc: 'A256GCM' }) + + const { payload, protectedHeader } = await jwtDecrypt(jwe, privateKey); + + console.log(payload); + console.log(protectedHeader); + + logger.info('JWT.decrypt... jws=', jws) + return jws.toString() + */ + } catch (e) { + logger.error('Failed to decrypt JWT:', e) + throw e + } +} diff --git a/backend/src/auth/jwt/payloadtypes/EncryptedJWEJwtPayloadType.ts b/backend/src/auth/jwt/payloadtypes/EncryptedJWEJwtPayloadType.ts new file mode 100644 index 000000000..31f7d5377 --- /dev/null +++ b/backend/src/auth/jwt/payloadtypes/EncryptedJWEJwtPayloadType.ts @@ -0,0 +1,18 @@ +// import { JWTPayload } from 'jose' +import { JwtPayloadType } from './JwtPayloadType' + +export class EncryptedJWEJwtPayloadType extends JwtPayloadType { + static ENCRYPTED_JWE_TYPE = 'encrypted-jwe' + + jwe: string + + constructor( + jwe: string, + ) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + super() + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + this.tokentype = EncryptedJWEJwtPayloadType.ENCRYPTED_JWE_TYPE + this.jwe = jwe + } +}