JWT in core module

This commit is contained in:
clauspeterhuebner 2025-07-02 18:14:10 +02:00
parent 495017c404
commit 06881716bb
12 changed files with 524 additions and 1 deletions

View File

@ -31,6 +31,7 @@
"dependencies": {
"database": "*",
"esbuild": "^0.25.2",
"jose": "^4.14.4",
"log4js": "^6.9.1",
"zod": "^3.25.61"
},

View File

@ -0,0 +1,158 @@
// 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('http://localhost:5001/api/'), keypairComA.privateKey)
jwsComB = await encode(new OpenConnectionJwtPayloadType('http://localhost:5002/api/'), keypairComB.privateKey)
})
it('decode jwsComA', async () => {
const decodedJwsComA = await decode(jwsComA)
expect(decodedJwsComA).toEqual({
expiration: '10m',
tokentype: OpenConnectionJwtPayloadType.OPEN_CONNECTION_TYPE,
url: 'http://localhost:5001/api/',
})
})
it('decode jwsComB', async () => {
const decodedJwsComB = await decode(jwsComB)
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)
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)
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)
jweComB = await encrypt(new OpenConnectionJwtPayloadType('http://localhost:5002/api/'), keypairComA.publicKey)
})
it('decrypt jweComA', async () => {
const decryptedAJwT = await decrypt(jweComA, keypairComB.privateKey)
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)
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 jweComB: string
let jwsComB: string
beforeEach(async () => {
jest.clearAllMocks()
jweComA = await encrypt(new OpenConnectionJwtPayloadType('http://localhost:5001/api/'), keypairComB.publicKey)
jwsComA = await encode(new EncryptedJWEJwtPayloadType(jweComA), keypairComA.privateKey)
jweComB = await encrypt(new OpenConnectionJwtPayloadType('http://localhost:5002/api/'), keypairComA.publicKey)
jwsComB = await encode(new EncryptedJWEJwtPayloadType(jweComB), keypairComB.privateKey)
})
it('verify jwsComA', async () => {
const verifiedJwsComA = await verify(jwsComA, keypairComA.publicKey)
expect(verifiedJwsComA).toEqual(expect.objectContaining({
payload: expect.objectContaining({
jwe: jweComA,
tokentype: EncryptedJWEJwtPayloadType.ENCRYPTED_JWE_TYPE,
})
}))
})
it('verify jwsComB', async () => {
const verifiedJwsComB = await verify(jwsComB, keypairComB.publicKey)
expect(verifiedJwsComB).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/',
}))
})
})
describe('test encryptAndSign and verifyAndDecrypt', () => {
let jwtComA: string
beforeEach(async () => {
jest.clearAllMocks()
jwtComA = await encryptAndSign(new OpenConnectionJwtPayloadType('http://localhost:5001/api/'), keypairComA.privateKey, keypairComB.publicKey)
})
it('verifyAndDecrypt jwtComA', async () => {
const verifiedAndDecryptedPayload = await verifyAndDecrypt(jwtComA, keypairComB.privateKey, keypairComA.publicKey)
expect(verifiedAndDecryptedPayload).toEqual(expect.objectContaining({
tokentype: OpenConnectionJwtPayloadType.OPEN_CONNECTION_TYPE,
url: 'http://localhost:5001/api/',
}))
})
})

152
core/src/auth/jwt/JWT.ts Normal file
View File

@ -0,0 +1,152 @@
import { generateKeyPair, exportSPKI, exportPKCS8, SignJWT, decodeJwt, importPKCS8, importSPKI, jwtVerify, CompactEncrypt, compactDecrypt } from 'jose'
import { LOG4JS_BASE_CATEGORY_NAME } from '../../config/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 (token: string, publicKey: string): Promise<JwtPayloadType | null> => {
if (!token) {
logger.error('verify... token is empty')
throw new Error('401 Unauthorized')
}
logger.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,
})
logger.debug('verify after jwtVerify... payload=', payload)
return payload as JwtPayloadType
} catch (err) {
logger.error('verify after jwtVerify... error=', err)
return null
}
}
export const encode = async (payload: JwtPayloadType, privatekey: string): Promise<string> => {
logger.debug('encode... payload=', payload)
logger.debug('encode... privatekey=', privatekey.substring(0, 10))
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)
return token
} catch (e) {
logger.error('Failed to sign JWT:', e)
throw e
}
}
export const verifyJwtType = async (token: string, publicKey: string): Promise<string> => {
const payload = await verify(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> => {
logger.debug('encrypt... payload=', payload)
logger.debug('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 jwe = await new CompactEncrypt(
new TextEncoder().encode(JSON.stringify(payload)),
)
.setProtectedHeader({ alg: 'RSA-OAEP-256', enc: 'A256GCM' })
.encrypt(recipientKey)
logger.debug('encrypt... jwe=', jwe)
return jwe.toString()
} catch (e) {
logger.error('Failed to encrypt JWT:', e)
throw e
}
}
export const decrypt = async(jwe: string, privateKey: string): Promise<string> => {
logger.debug('decrypt... jwe=', jwe)
logger.debug('decrypt... privateKey=', privateKey.substring(0, 10))
try {
const decryptKey = await importPKCS8(privateKey, 'RS256')
const { plaintext, protectedHeader } =
await compactDecrypt(jwe, decryptKey)
logger.debug('decrypt... plaintext=', plaintext)
logger.debug('decrypt... protectedHeader=', protectedHeader)
return plaintext.toString()
} catch (e) {
logger.error('Failed to decrypt JWT:', e)
throw e
}
}
export const encryptAndSign = async (payload: JwtPayloadType, privateKey: string, publicKey: string): Promise<string> => {
const jwe = await encrypt(payload, publicKey)
logger.debug('encryptAndSign... jwe=', jwe)
const jws = await encode(new EncryptedJWEJwtPayloadType(jwe), privateKey)
logger.debug('encryptAndSign... jws=', jws)
return jws
}
export const verifyAndDecrypt = async (token: string, privateKey: string, publicKey: string): Promise<JwtPayloadType | null> => {
const jweVerifyResult = await verify(token, publicKey)
if (!jweVerifyResult) {
return null
}
const jwePayload = jweVerifyResult.payload as EncryptedJWEJwtPayloadType
logger.debug('verifyAndDecrypt... jwePayload=', jwePayload)
if (!jwePayload) {
return null
}
const jwePayloadType = jwePayload.tokentype
if (jwePayloadType !== EncryptedJWEJwtPayloadType.ENCRYPTED_JWE_TYPE) {
return null
}
const jwe = jwePayload.jwe
logger.debug('verifyAndDecrypt... jwe=', jwe)
const payload = await decrypt(jwe as string, privateKey)
logger.debug('verifyAndDecrypt... payload=', payload)
return JSON.parse(payload) as JwtPayloadType
}

View File

@ -0,0 +1,48 @@
// import { JWTPayload } from 'jose'
import { JwtPayloadType } from './JwtPayloadType'
export class DisburseJwtPayloadType extends JwtPayloadType {
static DISBURSE_ACTIVATION_TYPE = 'disburse-activation'
sendercommunityuuid: string
sendergradidoid: string
recipientcommunityuuid: string
recipientcommunityname: string
recipientgradidoid: string
recipientfirstname: string
code: string
amount: string
memo: string
validuntil: string
recipientalias: string
constructor(
senderCommunityUuid: string,
senderGradidoId: string,
recipientCommunityUuid: string,
recipientCommunityName: string,
recipientGradidoId: string,
recipientFirstName: string,
code: string,
amount: string,
memo: string,
validUntil: string,
recipientAlias: string,
) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
super()
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
this.tokentype = DisburseJwtPayloadType.DISBURSE_ACTIVATION_TYPE
this.sendercommunityuuid = senderCommunityUuid
this.sendergradidoid = senderGradidoId
this.recipientcommunityuuid = recipientCommunityUuid
this.recipientcommunityname = recipientCommunityName
this.recipientgradidoid = recipientGradidoId
this.recipientfirstname = recipientFirstName
this.code = code
this.amount = amount
this.memo = memo
this.validuntil = validUntil
this.recipientalias = recipientAlias
}
}

View File

@ -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
}
}

View File

@ -0,0 +1,24 @@
import { JWTPayload } from 'jose'
import { REDEEM_JWT_TOKEN_EXPIRATION } from '../../../config/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
jti?: string | undefined
aud?: string | string[] | undefined
sub?: string | undefined
iss?: string | undefined;
[propName: string]: unknown
tokentype: string
expiration: string // in minutes (format: 10m for ten minutes)
constructor() {
this.tokentype = 'unknown jwt type'
this.expiration = REDEEM_JWT_TOKEN_EXPIRATION || '10m'
}
}

View File

@ -0,0 +1,21 @@
// import { JWTPayload } from 'jose'
import { JwtPayloadType } from './JwtPayloadType'
export class OpenConnectionCallbackJwtPayloadType extends JwtPayloadType {
static OPEN_CONNECTION_CALLBACK_TYPE = 'open-connection-callback'
oneTimeCode: string
url: string
constructor(
oneTimeCode: string,
url: string,
) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
super()
// 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 { JWTPayload } from 'jose'
import { JwtPayloadType } from './JwtPayloadType'
export class OpenConnectionJwtPayloadType extends JwtPayloadType {
static OPEN_CONNECTION_TYPE = 'open-connection'
url: string
constructor(
url: string,
) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
super()
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
this.tokentype = OpenConnectionJwtPayloadType.OPEN_CONNECTION_TYPE
this.url = url
}
}

View File

@ -0,0 +1,36 @@
// import { JWTPayload } from 'jose'
import { JwtPayloadType } from './JwtPayloadType'
export class RedeemJwtPayloadType extends JwtPayloadType {
static REDEEM_ACTIVATION_TYPE = 'redeem-activation'
sendercommunityuuid: string
sendergradidoid: string
sendername: string // alias or firstname
redeemcode: string
amount: string
memo: string
validuntil: string
constructor(
senderCom: string,
senderUser: string,
sendername: string,
code: string,
amount: string,
memo: string,
validUntil: string,
) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
super()
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
this.tokentype = RedeemJwtPayloadType.REDEEM_ACTIVATION_TYPE
this.sendercommunityuuid = senderCom
this.sendergradidoid = senderUser
this.sendername = sendername
this.redeemcode = code
this.amount = amount
this.memo = memo
this.validuntil = validUntil
}
}

View File

@ -1 +1,2 @@
export const LOG4JS_BASE_CATEGORY_NAME = 'core'
export const LOG4JS_BASE_CATEGORY_NAME = 'core'
export const REDEEM_JWT_TOKEN_EXPIRATION = '10m'

View File

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

View File

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