From 14fe14d9738546d7d8fbac0b7daffb67d69d61c9 Mon Sep 17 00:00:00 2001 From: Sagar Khole Date: Thu, 26 Mar 2026 13:41:42 +0530 Subject: [PATCH 01/18] feat: Implement status list allocation for OID4VC credential issuance Signed-off-by: Sagar Khole --- .../src/agent-service.controller.ts | 5 + .../src/agent-service.service.ts | 13 ++ .../oid4vc-issuance.controller.ts | 24 +++ .../oid4vc-issuance.service.ts | 5 + .../src/oid4vc-issuance.controller.ts | 13 +- .../src/oid4vc-issuance.module.ts | 10 +- .../src/oid4vc-issuance.service.ts | 177 +++++++++++++++- .../src/status-list-allocator.service.ts | 194 ++++++++++++++++++ libs/common/src/common.constant.ts | 1 + .../migration.sql | 44 ++++ libs/prisma-service/prisma/schema.prisma | 29 +++ 11 files changed, 497 insertions(+), 18 deletions(-) create mode 100644 apps/oid4vc-issuance/src/status-list-allocator.service.ts create mode 100644 libs/prisma-service/prisma/migrations/20260323183000_index_allocation/migration.sql diff --git a/apps/agent-service/src/agent-service.controller.ts b/apps/agent-service/src/agent-service.controller.ts index 9024f06e85..8faf3f5b7c 100644 --- a/apps/agent-service/src/agent-service.controller.ts +++ b/apps/agent-service/src/agent-service.controller.ts @@ -390,6 +390,11 @@ export class AgentServiceController { return this.agentServiceService.oidcDeleteCredentialOffer(payload.url, payload.orgId); } + @MessagePattern({ cmd: 'agent-service-oid4vc-revoke-credential' }) + async oidcRevokeCredential(payload: { url: string; orgId: string; statusListDetails?: object }): Promise { + return this.agentServiceService.oidcRevokeCredential(payload.url, payload.orgId, payload.statusListDetails); + } + @MessagePattern({ cmd: 'agent-create-x509-certificate' }) async createX509Certificate(payload: { options: X509CreateCertificateOptions; diff --git a/apps/agent-service/src/agent-service.service.ts b/apps/agent-service/src/agent-service.service.ts index 19aec908bc..f5d47311f2 100644 --- a/apps/agent-service/src/agent-service.service.ts +++ b/apps/agent-service/src/agent-service.service.ts @@ -1489,6 +1489,19 @@ export class AgentServiceService { } } + async oidcRevokeCredential(url: string, orgId: string, statusListDetails?: object): Promise { + try { + const getApiKey = await this.getOrgAgentApiKey(orgId); + const data = await this.commonService + .httpPost(`${url}`, statusListDetails || {}, { headers: { authorization: getApiKey } }) + .then(async (response) => response); + return data; + } catch (error) { + this.logger.error(`Error in _oidcRevokeCredential in agent service : ${JSON.stringify(error)}`); + throw error; + } + } + async oidcIssuerTemplate(templatePayload, url: string, orgId: string): Promise { try { const getApiKey = await this.getOrgAgentApiKey(orgId); diff --git a/apps/api-gateway/src/oid4vc-issuance/oid4vc-issuance.controller.ts b/apps/api-gateway/src/oid4vc-issuance/oid4vc-issuance.controller.ts index 7979093446..453746db25 100644 --- a/apps/api-gateway/src/oid4vc-issuance/oid4vc-issuance.controller.ts +++ b/apps/api-gateway/src/oid4vc-issuance/oid4vc-issuance.controller.ts @@ -664,4 +664,28 @@ export class Oid4vcIssuanceController { return res.status(HttpStatus.CREATED).json(finalResponse); } + + @Post('/orgs/:orgId/openid4vc/issuance-sessions/:issuanceSessionId/revoke') + @ApiOperation({ + summary: 'Revoke an OID4VC Credential', + description: 'Instantly revokes a credential issued during a specific OID4VC session.' + }) + @ApiResponse({ status: HttpStatus.OK, description: 'Credential revoked successfully.', type: ApiResponseDto }) + @ApiBearerAuth() + @Roles(OrgRoles.OWNER) + @UseGuards(AuthGuard('jwt'), OrgRolesGuard) + async revokeCredential( + @Param('orgId') orgId: string, + @Param('issuanceSessionId') issuanceSessionId: string, + @Res() res: Response + ): Promise { + const revoked = await this.oid4vcIssuanceService.revokeCredential(issuanceSessionId, orgId); + + const finalResponse: IResponse = { + statusCode: HttpStatus.OK, + message: 'Credential revoked successfully.', + data: revoked + }; + return res.status(HttpStatus.OK).json(finalResponse); + } } diff --git a/apps/api-gateway/src/oid4vc-issuance/oid4vc-issuance.service.ts b/apps/api-gateway/src/oid4vc-issuance/oid4vc-issuance.service.ts index 98a5d3e6fe..cf675a18f3 100644 --- a/apps/api-gateway/src/oid4vc-issuance/oid4vc-issuance.service.ts +++ b/apps/api-gateway/src/oid4vc-issuance/oid4vc-issuance.service.ts @@ -130,6 +130,11 @@ export class Oid4vcIssuanceService extends BaseService { return this.natsClient.sendNatsMessage(this.issuanceProxy, 'oid4vc-credential-offer-delete', payload); } + async revokeCredential(issuanceSessionId: string, orgId: string): Promise { + const payload = { issuanceSessionId, orgId }; + return this.natsClient.sendNatsMessage(this.issuanceProxy, 'oid4vc-revoke-credential', payload); + } + oidcIssueCredentialWebhook( oidcIssueCredentialDto: OidcIssueCredentialDto, id: string diff --git a/apps/oid4vc-issuance/src/oid4vc-issuance.controller.ts b/apps/oid4vc-issuance/src/oid4vc-issuance.controller.ts index b1e471fc37..0d31ae7472 100644 --- a/apps/oid4vc-issuance/src/oid4vc-issuance.controller.ts +++ b/apps/oid4vc-issuance/src/oid4vc-issuance.controller.ts @@ -165,16 +165,17 @@ export class Oid4vcIssuanceController { } @MessagePattern({ cmd: 'oid4vc-credential-offer-delete' }) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - async deleteCredentialOffers(payload: { - orgId: string; - credentialId: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - }): Promise { + async deleteCredentialOffers(payload: { orgId: string; credentialId: string }): Promise { const { orgId, credentialId } = payload; return this.oid4vcIssuanceService.deleteCredentialOffers(orgId, credentialId); } + @MessagePattern({ cmd: 'oid4vc-revoke-credential' }) + async revokeCredential(payload: { issuanceSessionId: string; orgId: string }): Promise { + const { issuanceSessionId, orgId } = payload; + return this.oid4vcIssuanceService.revokeCredential(issuanceSessionId, orgId); + } + @MessagePattern({ cmd: 'webhook-oid4vc-issue-credential' }) async oidcIssueCredentialWebhook(payload: Oid4vcCredentialOfferWebhookPayload): Promise { return this.oid4vcIssuanceService.storeOidcCredentialWebhook(payload); diff --git a/apps/oid4vc-issuance/src/oid4vc-issuance.module.ts b/apps/oid4vc-issuance/src/oid4vc-issuance.module.ts index b882032cbf..9b1d417e18 100644 --- a/apps/oid4vc-issuance/src/oid4vc-issuance.module.ts +++ b/apps/oid4vc-issuance/src/oid4vc-issuance.module.ts @@ -13,6 +13,7 @@ import { ConfigModule as PlatformConfig } from '@credebl/config/config.module'; import { Oid4vcIssuanceRepository } from './oid4vc-issuance.repository'; import { NATSClient } from '@credebl/common/NATSClient'; import { PrismaService } from '@credebl/prisma-service'; +import { StatusListAllocatorService } from './status-list-allocator.service'; @Module({ imports: [ @@ -31,6 +32,13 @@ import { PrismaService } from '@credebl/prisma-service'; CacheModule.register() ], controllers: [Oid4vcIssuanceController], - providers: [Oid4vcIssuanceService, Oid4vcIssuanceRepository, PrismaService, Logger, NATSClient] + providers: [ + Oid4vcIssuanceService, + Oid4vcIssuanceRepository, + PrismaService, + Logger, + NATSClient, + StatusListAllocatorService + ] }) export class Oid4vcIssuanceModule {} diff --git a/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts b/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts index 1abe9578ec..b786de3a9d 100644 --- a/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts +++ b/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts @@ -10,6 +10,7 @@ import { BadRequestException, ConflictException, HttpException, + HttpStatus, Inject, Injectable, InternalServerErrorException, @@ -18,6 +19,8 @@ import { Scope } from '@nestjs/common'; import { Oid4vcIssuanceRepository } from './oid4vc-issuance.repository'; +import { StatusListAllocatorService } from './status-list-allocator.service'; +import { CredentialFormat } from '@credebl/enum/enum'; import { CommonConstants } from '@credebl/common/common.constant'; import { ResponseMessages } from '@credebl/common/response-messages'; import { ClientProxy, RpcException } from '@nestjs/microservices'; @@ -79,7 +82,8 @@ export class Oid4vcIssuanceService { private readonly logger = new Logger('IssueCredentialService'); constructor( @Inject('NATS_CLIENT') private readonly issuanceServiceProxy: ClientProxy, - private readonly oid4vcIssuanceRepository: Oid4vcIssuanceRepository + private readonly oid4vcIssuanceRepository: Oid4vcIssuanceRepository, + private readonly statusListAllocatorService: StatusListAllocatorService ) {} async oidcIssuerCreate(issuerCreation: IssuerCreation, orgId: string, userDetails: user): Promise { @@ -524,6 +528,7 @@ export class Oid4vcIssuanceService { userDetails: user, issuerId: string ): Promise { + let buildOidcCredentialOffer: CredentialOfferPayload; try { const filterTemplateIds = extractTemplateIds(createOidcCredentialOffer); if (!filterTemplateIds) { @@ -594,7 +599,7 @@ export class Oid4vcIssuanceService { //TODO: add logic to pass the issuer info const issuerDetailsFromDb = await this.oid4vcIssuanceRepository.getOidcIssuerDetailsById(issuerId); const { publicIssuerId, authorizationServerUrl } = issuerDetailsFromDb || {}; - const buildOidcCredentialOffer: CredentialOfferPayload = buildCredentialOfferPayload( + buildOidcCredentialOffer = buildCredentialOfferPayload( createOidcCredentialOffer, // getAllOfferTemplates, @@ -619,14 +624,65 @@ export class Oid4vcIssuanceService { CommonConstants.OIDC_ISSUER_SESSIONS_CREDENTIAL_OFFER, issuerDetails.publicIssuerId ); - this.logger.debug(`Creating OIDC Credential Offer for :`, buildOidcCredentialOffer); + + const agentDetailsForAlloc = await this.oid4vcIssuanceRepository.getAgentEndPoint(orgId); + for (const cred of buildOidcCredentialOffer.credentials) { + if (cred.format === CredentialFormat.SdJwtVc || cred.format === CredentialFormat.Mdoc) { + const allocation = await this.statusListAllocatorService.allocate(orgId, agentDetailsForAlloc.orgDid); + (cred as any).statusListDetails = { + listId: allocation.listId, + index: allocation.index, + listSize: CommonConstants.DEFAULT_STATUS_LIST_SIZE + }; + } + } + const createCredentialOfferOnAgent = await this._oidcCreateCredentialOffer(buildOidcCredentialOffer, url, orgId); if (!createCredentialOfferOnAgent) { throw new NotFoundException(ResponseMessages.oidcIssuerSession.error.errorCreateOffer); } + let parsedResponse; + if ('string' === typeof createCredentialOfferOnAgent.response) { + parsedResponse = JSON.parse(createCredentialOfferOnAgent.response); + } else { + parsedResponse = createCredentialOfferOnAgent.response; + } + + const issuanceSessionId = + parsedResponse.issuanceSessionId || parsedResponse.credentialOfferId || parsedResponse.id; + if (issuanceSessionId) { + for (const cred of buildOidcCredentialOffer.credentials) { + if ((cred as any).statusListDetails) { + const statusListUri = `${process.env.STATUS_LIST_HOST}/status-lists/${(cred as any).statusListDetails.listId}`; + await this.statusListAllocatorService.saveCredentialAllocation( + String(cred.credentialSupportedId), + (cred as any).statusListDetails.listId, + (cred as any).statusListDetails.index, + issuanceSessionId, + statusListUri + ); + } + } + } + return createCredentialOfferOnAgent.response; } catch (error) { + if (buildOidcCredentialOffer?.credentials) { + for (const cred of buildOidcCredentialOffer.credentials) { + if ((cred as any).statusListDetails) { + try { + await this.statusListAllocatorService.release( + (cred as any).statusListDetails.listId, + (cred as any).statusListDetails.index + ); + } catch (releaseErr) { + this.logger.warn(`Failed to release index on rollback: ${releaseErr.message}`); + } + } + } + } + const errorResponse = ErrorHandler.categorize(error, 'Failed to create credential offer'); this.logger.error( `[createOidcCredentialOffer] - ${errorResponse.statusCode}: ${errorResponse.message}`, @@ -675,13 +731,69 @@ export class Oid4vcIssuanceService { await this.getAgentEndpoint(orgId), CommonConstants.OIDC_ISSUER_SESSIONS_CREDENTIAL_OFFER ); + + const agentDetailsForAlloc = await this.oid4vcIssuanceRepository.getAgentEndPoint(orgId); + for (const cred of oidcCredentialD2APayload.credentials) { + if (!cred.statusListDetails) { + try { + const allocation = await this.statusListAllocatorService.allocate(orgId, agentDetailsForAlloc.orgDid); + cred.statusListDetails = { + listId: allocation.listId, + index: allocation.index, + listSize: CommonConstants.DEFAULT_STATUS_LIST_SIZE + }; + } catch (allocError) { + this.logger.warn(`Could not allocate status list index: ${allocError.message}`); + } + } + } + const createCredentialOfferOnAgent = await this._oidcCreateCredentialOffer(oidcCredentialD2APayload, url, orgId); if (!createCredentialOfferOnAgent) { throw new NotFoundException(ResponseMessages.oidcIssuerSession.error.errorCreateOffer); } + // Save allocations now that we have the session id + let parsedResponse; + if ('string' === typeof createCredentialOfferOnAgent.response) { + parsedResponse = JSON.parse(createCredentialOfferOnAgent.response); + } else { + parsedResponse = createCredentialOfferOnAgent.response; + } + + const issuanceSessionId = + parsedResponse.issuanceSessionId || parsedResponse.credentialOfferId || parsedResponse.id; + if (issuanceSessionId) { + for (const cred of oidcCredentialD2APayload.credentials) { + if (cred.statusListDetails) { + const statusListUri = `${process.env.STATUS_LIST_HOST || 'http://10.0.0.77:3000'}/status-lists/${cred.statusListDetails.listId}`; + await this.statusListAllocatorService.saveCredentialAllocation( + String(cred.credentialSupportedId || cred.id), + cred.statusListDetails.listId, + cred.statusListDetails.index, + issuanceSessionId, + statusListUri + ); + } + } + } + return createCredentialOfferOnAgent.response; } catch (error) { + if (oidcCredentialD2APayload?.credentials) { + for (const cred of oidcCredentialD2APayload.credentials) { + if (cred.statusListDetails) { + try { + await this.statusListAllocatorService.release( + cred.statusListDetails.listId, + cred.statusListDetails.index + ); + } catch (releaseErr) { + this.logger.warn(`Failed to release index on rollback: ${releaseErr.message}`); + } + } + } + } this.logger.error(`[createOidcCredentialOffer] - error: ${JSON.stringify(error)}`); throw new RpcException(error.response ?? error); } @@ -763,7 +875,50 @@ export class Oid4vcIssuanceService { } return deletedCredentialOffer.response; } catch (error) { - this.logger.error(`[getCredentialOffers] - error: ${JSON.stringify(error)}`); + this.logger.error(`[deleteCredentialOffers] - error: ${JSON.stringify(error)}`); + throw new RpcException(error.response ?? error); + } + } + + async revokeCredential(issuanceSessionId: string, orgId: string): Promise { + try { + if (!issuanceSessionId) { + throw new BadRequestException('Please provide a valid issuanceSessionId'); + } + + const allocations = await this.statusListAllocatorService.getCredentialAllocations(issuanceSessionId); + + const url = getAgentUrl( + await this.getAgentEndpoint(orgId), + CommonConstants.OIDC_ISSUER_SESSIONS_BY_ID, + `${issuanceSessionId}/revoke` + ); + + const pattern = { cmd: 'agent-service-oid4vc-revoke-credential' }; + + let lastResponse; + if (allocations && 0 < allocations.length) { + for (const allocation of allocations) { + const payload: any = { + url, + orgId, + statusListDetails: { + listId: allocation.listId, + index: allocation.index, + listSize: CommonConstants.DEFAULT_STATUS_LIST_SIZE + } + }; + lastResponse = await this.natsCall(pattern, payload); + } + } else { + // Fallback for cases where no status list allocations exist + const payload: any = { url, orgId }; + lastResponse = await this.natsCall(pattern, payload); + } + + return lastResponse; + } catch (error) { + this.logger.error(`[revokeCredential] - error: ${JSON.stringify(error)}`); throw new RpcException(error.response ?? error); } } @@ -897,7 +1052,7 @@ export class Oid4vcIssuanceService { async _oidcGetCredentialOffers(url: string, orgId: string) { try { - const pattern = { cmd: 'agent-service-oid4vc-get-credential-offers' }; + const pattern = { cmd: 'agent-service-oid4vc-get-all-credential-offers' }; const payload = { url, orgId }; return this.natsCall(pattern, payload); } catch (error) { @@ -940,10 +1095,10 @@ export class Oid4vcIssuanceService { this.logger.error(`catch: ${JSON.stringify(error)}`); throw new HttpException( { - status: error.statusCode, + status: error.statusCode || HttpStatus.INTERNAL_SERVER_ERROR, error: error.message }, - error.error + error.statusCode || error.status || HttpStatus.INTERNAL_SERVER_ERROR ); }); } catch (error) { @@ -971,16 +1126,16 @@ export class Oid4vcIssuanceService { ): Promise { try { // pick fields - let organisationId: string; const { oidcIssueCredentialDto, id } = CredentialOfferWebhookPayload; + let organisationId = id; if ('default' !== oidcIssueCredentialDto?.contextCorrelationId) { const getOrganizationId = await this.oid4vcIssuanceRepository.getOrganizationByTenantId( oidcIssueCredentialDto?.contextCorrelationId ); - organisationId = getOrganizationId?.orgId; - } else { - organisationId = id; + if (getOrganizationId?.orgId) { + organisationId = getOrganizationId.orgId; + } } const { diff --git a/apps/oid4vc-issuance/src/status-list-allocator.service.ts b/apps/oid4vc-issuance/src/status-list-allocator.service.ts new file mode 100644 index 0000000000..fbdae296db --- /dev/null +++ b/apps/oid4vc-issuance/src/status-list-allocator.service.ts @@ -0,0 +1,194 @@ +/* eslint-disable camelcase */ +import { Injectable, Logger } from '@nestjs/common'; +import { PrismaService } from '@credebl/prisma-service'; +import { issued_oid4vc_credentials } from '@prisma/client'; +import { CommonConstants } from '@credebl/common/common.constant'; +import { randomInt, randomUUID } from 'crypto'; + +export class RandomBitmapIndexAllocator { + private bitmap: Uint8Array; + private capacity: number; + private allocatedCount: number; + + constructor(capacity: number, existing?: Uint8Array) { + this.capacity = capacity; + this.bitmap = existing ? new Uint8Array(existing) : new Uint8Array(Math.ceil(capacity / 8)); + + this.allocatedCount = 0; + + for (const byte of this.bitmap) { + if (0 !== byte) { + this.allocatedCount += byte.toString(2).split('1').length - 1; + } + } + } + + private isSet(index: number): boolean { + const byteIndex = index >> 3; + const bit = index & 7; + return 0 !== (this.bitmap[byteIndex] & (1 << bit)); + } + + private set(index: number): void { + const byteIndex = index >> 3; + const bit = index & 7; + this.bitmap[byteIndex] |= 1 << bit; + } + + private clear(index: number): void { + const byteIndex = index >> 3; + const bit = index & 7; + this.bitmap[byteIndex] &= ~(1 << bit); + } + + public allocate(): number { + if (this.allocatedCount === this.capacity) { + throw new Error('No indexes left'); + } + + while (true) { + const idx = randomInt(this.capacity); + + if (!this.isSet(idx)) { + this.set(idx); + this.allocatedCount++; + return idx; + } + } + } + + public isIndexAllocated(index: number): boolean { + if (0 > index || index >= this.capacity) { + throw new Error('Invalid index'); + } + return this.isSet(index); + } + + public release(index: number): void { + if (0 > index || index >= this.capacity) { + throw new Error('Invalid index'); + } + + if (this.isSet(index)) { + this.clear(index); + this.allocatedCount--; + } + } + + public export(): Uint8Array { + return new Uint8Array(this.bitmap); + } +} + +@Injectable() +export class StatusListAllocatorService { + private readonly logger = new Logger(StatusListAllocatorService.name); + + constructor(private readonly prisma: PrismaService) {} + + async allocate(orgId: string, issuerDid: string, listSize?: number): Promise<{ listId: string; index: number }> { + const defaultListSize = CommonConstants.DEFAULT_STATUS_LIST_SIZE; + return this.prisma.$transaction(async (tx) => { + // Find active list or create one + let activeList = await tx.status_list_allocation.findFirst({ + where: { orgId, issuerDid, isActive: true } + }); + + if (!activeList || activeList.allocatedCount >= activeList.listSize) { + // Mark inactive if full + if (activeList) { + await tx.status_list_allocation.update({ + where: { id: activeList.id }, + data: { isActive: false } + }); + } + + activeList = await tx.status_list_allocation.create({ + data: { + orgId, + issuerDid, + listId: randomUUID(), + listSize: listSize || defaultListSize, + allocatedCount: 0, + bitmap: Buffer.from(new Uint8Array(Math.ceil((listSize || defaultListSize) / 8))), + isActive: true + } + }); + } + + const allocator = new RandomBitmapIndexAllocator(activeList.listSize, new Uint8Array(activeList.bitmap)); + + try { + const index = allocator.allocate(); + + // Update database + await tx.status_list_allocation.update({ + where: { id: activeList.id }, + data: { + bitmap: Buffer.from(allocator.export()), + allocatedCount: activeList.allocatedCount + 1 + } + }); + + return { listId: activeList.listId, index }; + } catch (error) { + if ('No indexes left' === error.message) { + // Retry rotation if we hit the race condition limit + await tx.status_list_allocation.update({ + where: { id: activeList.id }, + data: { isActive: false } + }); + throw new Error('Retry allocation, list full'); + } + throw error; + } + }); + } + + async saveCredentialAllocation( + credentialId: string, + listId: string, + index: number, + issuanceSessionId: string, + statusListUri: string + ): Promise { + await this.prisma.issued_oid4vc_credentials.create({ + data: { + credentialId, + listId, + index, + issuanceSessionId, + statusListUri + } + }); + } + + async getCredentialAllocations(issuanceSessionId: string): Promise { + return this.prisma.issued_oid4vc_credentials.findMany({ + where: { issuanceSessionId } + }); + } + + async release(listId: string, index: number): Promise { + await this.prisma.$transaction(async (tx) => { + const allocation = await tx.status_list_allocation.findUnique({ + where: { listId } + }); + + if (!allocation) { + return; + } + + const allocator = new RandomBitmapIndexAllocator(allocation.listSize, new Uint8Array(allocation.bitmap)); + allocator.release(index); + + await tx.status_list_allocation.update({ + where: { listId }, + data: { + bitmap: Buffer.from(allocator.export()), + allocatedCount: Math.max(0, allocation.allocatedCount - 1) + } + }); + }); + } +} diff --git a/libs/common/src/common.constant.ts b/libs/common/src/common.constant.ts index d1cb0a52e2..2c9ab7878d 100644 --- a/libs/common/src/common.constant.ts +++ b/libs/common/src/common.constant.ts @@ -229,6 +229,7 @@ export enum CommonConstants { // eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values DEFAULT_CACHE_TTL = 60000, + DEFAULT_STATUS_LIST_SIZE = 131072, DEFAULT_FIELD_UPLOAD_SIZE = 10485760, // SUBSCRIPTION TYPES diff --git a/libs/prisma-service/prisma/migrations/20260323183000_index_allocation/migration.sql b/libs/prisma-service/prisma/migrations/20260323183000_index_allocation/migration.sql new file mode 100644 index 0000000000..c4e22f74bd --- /dev/null +++ b/libs/prisma-service/prisma/migrations/20260323183000_index_allocation/migration.sql @@ -0,0 +1,44 @@ +-- CreateTable +CREATE TABLE "status_list_allocation" ( + "id" UUID NOT NULL, + "orgId" UUID NOT NULL, + "issuerDid" TEXT NOT NULL, + "listId" UUID NOT NULL, + "listSize" INTEGER NOT NULL DEFAULT 131072, + "allocatedCount" INTEGER NOT NULL DEFAULT 0, + "bitmap" BYTEA NOT NULL, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createDateTime" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "status_list_allocation_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "issued_oid4vc_credentials" ( + "id" UUID NOT NULL, + "credentialId" TEXT NOT NULL, + "listId" UUID NOT NULL, + "index" INTEGER NOT NULL, + "issuanceSessionId" VARCHAR NOT NULL, + "createDateTime" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "statusListUri" TEXT NOT NULL, + + CONSTRAINT "issued_oid4vc_credentials_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "status_list_allocation_listId_key" ON "status_list_allocation"("listId"); + +-- CreateIndex +CREATE INDEX "status_list_allocation_orgId_isActive_idx" ON "status_list_allocation"("orgId", "isActive"); + +-- CreateIndex +CREATE UNIQUE INDEX "issued_oid4vc_credentials_credentialId_key" ON "issued_oid4vc_credentials"("credentialId"); + +-- AddForeignKey +ALTER TABLE "status_list_allocation" ADD CONSTRAINT "status_list_allocation_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "organisation"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- Add webhookSecret safely +ALTER TABLE "org_agents" ADD COLUMN IF NOT EXISTS "webhookSecret" VARCHAR; diff --git a/libs/prisma-service/prisma/schema.prisma b/libs/prisma-service/prisma/schema.prisma index 1c0a912d6c..b530db2cc0 100755 --- a/libs/prisma-service/prisma/schema.prisma +++ b/libs/prisma-service/prisma/schema.prisma @@ -159,6 +159,7 @@ model organisation { intent_templates intent_templates[] ecosystemOrgs ecosystem_orgs[] ecosystem_invitations ecosystem_invitations[] + status_list_allocations status_list_allocation[] } model org_invitations { @@ -230,6 +231,7 @@ model org_agents { orgAgentTypeId String? @db.Uuid ledgerId String? @db.Uuid didDocument Json? + webhookSecret String? @db.VarChar agent_invitations agent_invitations[] agents agents? @relation(fields: [agentId], references: [id]) agents_type agents_type? @relation(fields: [agentsTypeId], references: [id]) @@ -630,6 +632,33 @@ model oid4vp_presentations { organisation organisation @relation(fields: [orgId], references: [id]) } +model status_list_allocation { + id String @id @default(uuid()) @db.Uuid + orgId String @db.Uuid + issuerDid String + listId String @unique @db.Uuid + listSize Int @default(131072) + allocatedCount Int @default(0) + bitmap Bytes + isActive Boolean @default(true) + createDateTime DateTime @default(now()) @db.Timestamptz(6) + updatedAt DateTime @updatedAt + organisation organisation @relation(fields: [orgId], references: [id]) + + @@index([orgId, isActive]) +} + +model issued_oid4vc_credentials { + id String @id @default(uuid()) @db.Uuid + credentialId String @unique + listId String @db.Uuid + index Int + issuanceSessionId String @db.VarChar + createDateTime DateTime @default(now()) @db.Timestamptz(6) + updatedAt DateTime @updatedAt + statusListUri String +} + enum SignerOption { DID X509_P256 From 2248f9b43f56bc9ad7584639ba0c9f83482b749a Mon Sep 17 00:00:00 2001 From: Sagar Khole Date: Thu, 26 Mar 2026 15:24:25 +0530 Subject: [PATCH 02/18] feat: Implement status list allocation for OID4VC credential issuance, Signed-off-by: Sagar Khole --- apps/oid4vc-issuance/src/main.ts | 5 +++++ apps/oid4vc-issuance/src/oid4vc-issuance.service.ts | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/oid4vc-issuance/src/main.ts b/apps/oid4vc-issuance/src/main.ts index be0701c3d7..84e0b7b0e1 100644 --- a/apps/oid4vc-issuance/src/main.ts +++ b/apps/oid4vc-issuance/src/main.ts @@ -10,6 +10,11 @@ import { Oid4vcIssuanceModule } from './oid4vc-issuance.module'; const logger = new Logger(); async function bootstrap(): Promise { + if (!process.env.STATUS_LIST_HOST) { + logger.error('STATUS_LIST_HOST is not configured. Microservice cannot start.'); + process.exit(1); + } + const app = await NestFactory.createMicroservice(Oid4vcIssuanceModule, { transport: Transport.NATS, options: getNatsOptions(CommonConstants.OIDC4VC_ISSUANCE_SERVICE, process.env.OIDC4VC_ISSUANCE_NKEY_SEED) diff --git a/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts b/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts index b786de3a9d..5ab6fefff6 100644 --- a/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts +++ b/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts @@ -766,7 +766,7 @@ export class Oid4vcIssuanceService { if (issuanceSessionId) { for (const cred of oidcCredentialD2APayload.credentials) { if (cred.statusListDetails) { - const statusListUri = `${process.env.STATUS_LIST_HOST || 'http://10.0.0.77:3000'}/status-lists/${cred.statusListDetails.listId}`; + const statusListUri = `${process.env.STATUS_LIST_HOST}/status-lists/${cred.statusListDetails.listId}`; await this.statusListAllocatorService.saveCredentialAllocation( String(cred.credentialSupportedId || cred.id), cred.statusListDetails.listId, From 812d6228672c6c2393e03674c53e1cfab77c0dae Mon Sep 17 00:00:00 2001 From: Sagar Khole Date: Thu, 26 Mar 2026 15:26:49 +0530 Subject: [PATCH 03/18] feat: Implement status list allocation for OID4VC credential issuance, Signed-off-by: Sagar Khole --- .env.demo | 3 ++- .env.sample | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.env.demo b/.env.demo index 18d5d2bd12..37d4fdb83e 100644 --- a/.env.demo +++ b/.env.demo @@ -244,4 +244,5 @@ CONSUMER_CONFIG_MAX_DELIVER=4 AGGREGATE_STREAM=aggregate DID_STREAM=did-notify -PULL_CONSUMER=hub-pull-consumer \ No newline at end of file +PULL_CONSUMER=hub-pull-consumer +STATUS_LIST_HOST= \ No newline at end of file diff --git a/.env.sample b/.env.sample index 823432c7d1..9cb242c880 100644 --- a/.env.sample +++ b/.env.sample @@ -272,4 +272,5 @@ CONSUMER_CONFIG_MAX_DELIVER=4 AGGREGATE_STREAM=aggregate DID_STREAM=did-notify -PULL_CONSUMER=hub-pull-consumer \ No newline at end of file +PULL_CONSUMER=hub-pull-consumer +STATUS_LIST_HOST= From 69c9eb3fddbd4eb5071e9277526928b8ea648a02 Mon Sep 17 00:00:00 2001 From: Sagar Khole Date: Thu, 26 Mar 2026 18:31:06 +0530 Subject: [PATCH 04/18] update OID4VC issuance logic Signed-off-by: Sagar Khole --- .../dtos/issuer-sessions.dto.ts | 10 ++- .../oid4vc-issuer-sessions.interfaces.ts | 1 + .../helpers/credential-sessions.builder.ts | 10 ++- .../src/oid4vc-issuance.service.ts | 74 ++++++++++--------- .../src/status-list-allocator.service.ts | 3 + 5 files changed, 63 insertions(+), 35 deletions(-) diff --git a/apps/api-gateway/src/oid4vc-issuance/dtos/issuer-sessions.dto.ts b/apps/api-gateway/src/oid4vc-issuance/dtos/issuer-sessions.dto.ts index d32d1a95f2..bc1d69b624 100644 --- a/apps/api-gateway/src/oid4vc-issuance/dtos/issuer-sessions.dto.ts +++ b/apps/api-gateway/src/oid4vc-issuance/dtos/issuer-sessions.dto.ts @@ -187,6 +187,10 @@ export class CreateOidcCredentialOfferDto { authorizationType!: 'preAuthorizedCodeFlow' | 'authorizationCodeFlow'; issuerId?: string; + + @ApiPropertyOptional({ example: true, description: 'Flag to enable revocation for the issued credentials' }) + @IsOptional() + isRevocable?: boolean; } export class GetAllCredentialOfferDto { @@ -292,7 +296,7 @@ export class CredentialDto { }, iat: 1698151532, nbf: dateToSeconds(new Date()), - exp: dateToSeconds(new Date(Date.now() + 5 * 365 * 24 * 60 * 60 * 1000)) + exp: dateToSeconds(new Date(Date.now() + 157680000000)) } ] }) @@ -382,6 +386,10 @@ export class CreateCredentialOfferD2ADto { @IsOptional() issuerId?: string; + @ApiPropertyOptional({ example: true, description: 'Flag to enable revocation for the issued credentials' }) + @IsOptional() + isRevocable?: boolean; + @ExactlyOneOf(['preAuthorizedCodeFlowConfig', 'authorizationCodeFlowConfig'], { message: 'Provide exactly one of preAuthorizedCodeFlowConfig or authorizationCodeFlowConfig.' }) diff --git a/apps/oid4vc-issuance/interfaces/oid4vc-issuer-sessions.interfaces.ts b/apps/oid4vc-issuance/interfaces/oid4vc-issuer-sessions.interfaces.ts index e463eda2bb..33b88e47a2 100644 --- a/apps/oid4vc-issuance/interfaces/oid4vc-issuer-sessions.interfaces.ts +++ b/apps/oid4vc-issuance/interfaces/oid4vc-issuer-sessions.interfaces.ts @@ -47,6 +47,7 @@ export interface CreateOidcCredentialOffer { // e.g. "abc-gov" authenticationType: AuthenticationType; // only option selector credentials: CredentialRequest[]; // one or more credentials + isRevocable?: boolean; } export interface GetAllCredentialOffer { diff --git a/apps/oid4vc-issuance/libs/helpers/credential-sessions.builder.ts b/apps/oid4vc-issuance/libs/helpers/credential-sessions.builder.ts index a2b8b208da..a76086122f 100644 --- a/apps/oid4vc-issuance/libs/helpers/credential-sessions.builder.ts +++ b/apps/oid4vc-issuance/libs/helpers/credential-sessions.builder.ts @@ -58,6 +58,7 @@ export interface CreateOidcCredentialOfferDtoLike { authorizationServerUrl: string; }; publicIssuerId?: string; + isRevocable?: boolean; } export interface ResolvedSignerOption { @@ -84,12 +85,18 @@ export interface BuiltCredential { format: CredentialFormat; payload: Record; disclosureFrame?: DisclosureFrame; + statusListDetails?: { + listId: string; + index: number; + listSize?: number; + }; } export interface BuiltCredentialOfferBase { signerOption?: ResolvedSignerOption; credentials: BuiltCredential[]; publicIssuerId?: string; + isRevocable?: boolean; } export type CredentialOfferPayload = BuiltCredentialOfferBase & @@ -477,7 +484,8 @@ export function buildCredentialOfferPayload( const baseEnvelope: BuiltCredentialOfferBase = { credentials: builtCredentials, - ...(finalPublicIssuerId ? { publicIssuerId: finalPublicIssuerId } : {}) + ...(finalPublicIssuerId ? { publicIssuerId: finalPublicIssuerId } : {}), + isRevocable: dto.isRevocable }; // Determine which authorization flow to return: diff --git a/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts b/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts index 5ab6fefff6..19a248d0df 100644 --- a/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts +++ b/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts @@ -625,15 +625,20 @@ export class Oid4vcIssuanceService { issuerDetails.publicIssuerId ); - const agentDetailsForAlloc = await this.oid4vcIssuanceRepository.getAgentEndPoint(orgId); - for (const cred of buildOidcCredentialOffer.credentials) { - if (cred.format === CredentialFormat.SdJwtVc || cred.format === CredentialFormat.Mdoc) { - const allocation = await this.statusListAllocatorService.allocate(orgId, agentDetailsForAlloc.orgDid); - (cred as any).statusListDetails = { - listId: allocation.listId, - index: allocation.index, - listSize: CommonConstants.DEFAULT_STATUS_LIST_SIZE - }; + if (createOidcCredentialOffer.isRevocable) { + const agentDetailsForAlloc = await this.oid4vcIssuanceRepository.getAgentEndPoint(orgId); + if (!agentDetailsForAlloc?.orgDid) { + throw new BadRequestException('Organization DID is required for revocable credentials'); + } + for (const cred of buildOidcCredentialOffer.credentials) { + if (cred.format === CredentialFormat.SdJwtVc || cred.format === CredentialFormat.Mdoc) { + const allocation = await this.statusListAllocatorService.allocate(orgId, agentDetailsForAlloc.orgDid); + cred.statusListDetails = { + listId: allocation.listId, + index: allocation.index, + listSize: CommonConstants.DEFAULT_STATUS_LIST_SIZE + }; + } } } @@ -651,14 +656,14 @@ export class Oid4vcIssuanceService { const issuanceSessionId = parsedResponse.issuanceSessionId || parsedResponse.credentialOfferId || parsedResponse.id; - if (issuanceSessionId) { + if (issuanceSessionId && createOidcCredentialOffer.isRevocable) { for (const cred of buildOidcCredentialOffer.credentials) { - if ((cred as any).statusListDetails) { - const statusListUri = `${process.env.STATUS_LIST_HOST}/status-lists/${(cred as any).statusListDetails.listId}`; + if (cred.statusListDetails) { + const statusListUri = `${process.env.STATUS_LIST_HOST}/status-lists/${cred.statusListDetails.listId}`; await this.statusListAllocatorService.saveCredentialAllocation( String(cred.credentialSupportedId), - (cred as any).statusListDetails.listId, - (cred as any).statusListDetails.index, + cred.statusListDetails.listId, + cred.statusListDetails.index, issuanceSessionId, statusListUri ); @@ -670,11 +675,11 @@ export class Oid4vcIssuanceService { } catch (error) { if (buildOidcCredentialOffer?.credentials) { for (const cred of buildOidcCredentialOffer.credentials) { - if ((cred as any).statusListDetails) { + if (cred.statusListDetails) { try { await this.statusListAllocatorService.release( - (cred as any).statusListDetails.listId, - (cred as any).statusListDetails.index + cred.statusListDetails.listId, + cred.statusListDetails.index ); } catch (releaseErr) { this.logger.warn(`Failed to release index on rollback: ${releaseErr.message}`); @@ -732,18 +737,23 @@ export class Oid4vcIssuanceService { CommonConstants.OIDC_ISSUER_SESSIONS_CREDENTIAL_OFFER ); - const agentDetailsForAlloc = await this.oid4vcIssuanceRepository.getAgentEndPoint(orgId); - for (const cred of oidcCredentialD2APayload.credentials) { - if (!cred.statusListDetails) { - try { - const allocation = await this.statusListAllocatorService.allocate(orgId, agentDetailsForAlloc.orgDid); - cred.statusListDetails = { - listId: allocation.listId, - index: allocation.index, - listSize: CommonConstants.DEFAULT_STATUS_LIST_SIZE - }; - } catch (allocError) { - this.logger.warn(`Could not allocate status list index: ${allocError.message}`); + if (oidcCredentialD2APayload.isRevocable) { + const agentDetailsForAlloc = await this.oid4vcIssuanceRepository.getAgentEndPoint(orgId); + if (!agentDetailsForAlloc?.orgDid) { + throw new BadRequestException('Organization DID is required for revocable credentials'); + } + for (const cred of oidcCredentialD2APayload.credentials) { + if (!cred.statusListDetails) { + try { + const allocation = await this.statusListAllocatorService.allocate(orgId, agentDetailsForAlloc.orgDid); + cred.statusListDetails = { + listId: allocation.listId, + index: allocation.index, + listSize: CommonConstants.DEFAULT_STATUS_LIST_SIZE + }; + } catch (allocError) { + this.logger.warn(`Could not allocate status list index: ${allocError.message}`); + } } } } @@ -763,7 +773,7 @@ export class Oid4vcIssuanceService { const issuanceSessionId = parsedResponse.issuanceSessionId || parsedResponse.credentialOfferId || parsedResponse.id; - if (issuanceSessionId) { + if (issuanceSessionId && oidcCredentialD2APayload.isRevocable) { for (const cred of oidcCredentialD2APayload.credentials) { if (cred.statusListDetails) { const statusListUri = `${process.env.STATUS_LIST_HOST}/status-lists/${cred.statusListDetails.listId}`; @@ -911,9 +921,7 @@ export class Oid4vcIssuanceService { lastResponse = await this.natsCall(pattern, payload); } } else { - // Fallback for cases where no status list allocations exist - const payload: any = { url, orgId }; - lastResponse = await this.natsCall(pattern, payload); + throw new BadRequestException('Credential is not revocable as no status list allocation was found.'); } return lastResponse; diff --git a/apps/oid4vc-issuance/src/status-list-allocator.service.ts b/apps/oid4vc-issuance/src/status-list-allocator.service.ts index fbdae296db..c44c81452c 100644 --- a/apps/oid4vc-issuance/src/status-list-allocator.service.ts +++ b/apps/oid4vc-issuance/src/status-list-allocator.service.ts @@ -87,6 +87,9 @@ export class StatusListAllocatorService { constructor(private readonly prisma: PrismaService) {} async allocate(orgId: string, issuerDid: string, listSize?: number): Promise<{ listId: string; index: number }> { + if (!orgId || !issuerDid) { + throw new Error('orgId and issuerDid are required for status list allocation'); + } const defaultListSize = CommonConstants.DEFAULT_STATUS_LIST_SIZE; return this.prisma.$transaction(async (tx) => { // Find active list or create one From 9294e404948e09c428480df3b7adedd298c8487c Mon Sep 17 00:00:00 2001 From: Sagar Khole Date: Fri, 27 Mar 2026 10:31:46 +0530 Subject: [PATCH 05/18] fix:sd-jwt revocation flow Signed-off-by: Sagar Khole --- .../src/oid4vc-issuance.service.ts | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts b/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts index 19a248d0df..abe48d42d8 100644 --- a/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts +++ b/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts @@ -636,7 +636,7 @@ export class Oid4vcIssuanceService { cred.statusListDetails = { listId: allocation.listId, index: allocation.index, - listSize: CommonConstants.DEFAULT_STATUS_LIST_SIZE + listSize: Number(CommonConstants.DEFAULT_STATUS_LIST_SIZE) }; } } @@ -655,13 +655,16 @@ export class Oid4vcIssuanceService { } const issuanceSessionId = - parsedResponse.issuanceSessionId || parsedResponse.credentialOfferId || parsedResponse.id; + parsedResponse.issuanceSessionId || + parsedResponse.credentialOfferId || + parsedResponse.id || + parsedResponse.issuanceSession?.id; if (issuanceSessionId && createOidcCredentialOffer.isRevocable) { for (const cred of buildOidcCredentialOffer.credentials) { if (cred.statusListDetails) { const statusListUri = `${process.env.STATUS_LIST_HOST}/status-lists/${cred.statusListDetails.listId}`; await this.statusListAllocatorService.saveCredentialAllocation( - String(cred.credentialSupportedId), + `${issuanceSessionId}-${cred.statusListDetails.index}`, cred.statusListDetails.listId, cred.statusListDetails.index, issuanceSessionId, @@ -749,7 +752,7 @@ export class Oid4vcIssuanceService { cred.statusListDetails = { listId: allocation.listId, index: allocation.index, - listSize: CommonConstants.DEFAULT_STATUS_LIST_SIZE + listSize: Number(CommonConstants.DEFAULT_STATUS_LIST_SIZE) }; } catch (allocError) { this.logger.warn(`Could not allocate status list index: ${allocError.message}`); @@ -772,13 +775,16 @@ export class Oid4vcIssuanceService { } const issuanceSessionId = - parsedResponse.issuanceSessionId || parsedResponse.credentialOfferId || parsedResponse.id; + parsedResponse.issuanceSessionId || + parsedResponse.credentialOfferId || + parsedResponse.id || + parsedResponse.issuanceSession?.id; if (issuanceSessionId && oidcCredentialD2APayload.isRevocable) { for (const cred of oidcCredentialD2APayload.credentials) { if (cred.statusListDetails) { const statusListUri = `${process.env.STATUS_LIST_HOST}/status-lists/${cred.statusListDetails.listId}`; await this.statusListAllocatorService.saveCredentialAllocation( - String(cred.credentialSupportedId || cred.id), + `${issuanceSessionId}-${cred.statusListDetails.index}`, cred.statusListDetails.listId, cred.statusListDetails.index, issuanceSessionId, @@ -915,7 +921,7 @@ export class Oid4vcIssuanceService { statusListDetails: { listId: allocation.listId, index: allocation.index, - listSize: CommonConstants.DEFAULT_STATUS_LIST_SIZE + listSize: Number(CommonConstants.DEFAULT_STATUS_LIST_SIZE) } }; lastResponse = await this.natsCall(pattern, payload); From 3464eb0cd2e38e2e91ec8d8c44dd88ca8ff10812 Mon Sep 17 00:00:00 2001 From: Sagar Khole Date: Fri, 27 Mar 2026 11:02:15 +0530 Subject: [PATCH 06/18] fix: restrict status list allocation to SD-JWT format only Signed-off-by: Sagar Khole --- .../src/oid4vc-issuance.service.ts | 61 +++++++++++-------- 1 file changed, 34 insertions(+), 27 deletions(-) diff --git a/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts b/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts index abe48d42d8..4fd1aa9da0 100644 --- a/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts +++ b/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts @@ -626,18 +626,21 @@ export class Oid4vcIssuanceService { ); if (createOidcCredentialOffer.isRevocable) { - const agentDetailsForAlloc = await this.oid4vcIssuanceRepository.getAgentEndPoint(orgId); - if (!agentDetailsForAlloc?.orgDid) { - throw new BadRequestException('Organization DID is required for revocable credentials'); - } - for (const cred of buildOidcCredentialOffer.credentials) { - if (cred.format === CredentialFormat.SdJwtVc || cred.format === CredentialFormat.Mdoc) { - const allocation = await this.statusListAllocatorService.allocate(orgId, agentDetailsForAlloc.orgDid); - cred.statusListDetails = { - listId: allocation.listId, - index: allocation.index, - listSize: Number(CommonConstants.DEFAULT_STATUS_LIST_SIZE) - }; + const hasSdJwt = buildOidcCredentialOffer.credentials.some((c) => c.format === CredentialFormat.SdJwtVc); + if (hasSdJwt) { + const agentDetailsForAlloc = await this.oid4vcIssuanceRepository.getAgentEndPoint(orgId); + if (!agentDetailsForAlloc?.orgDid) { + throw new BadRequestException('Organization DID is required for revocable SD-JWT credentials'); + } + for (const cred of buildOidcCredentialOffer.credentials) { + if (cred.format === CredentialFormat.SdJwtVc) { + const allocation = await this.statusListAllocatorService.allocate(orgId, agentDetailsForAlloc.orgDid); + cred.statusListDetails = { + listId: allocation.listId, + index: allocation.index, + listSize: Number(CommonConstants.DEFAULT_STATUS_LIST_SIZE) + }; + } } } } @@ -741,21 +744,25 @@ export class Oid4vcIssuanceService { ); if (oidcCredentialD2APayload.isRevocable) { - const agentDetailsForAlloc = await this.oid4vcIssuanceRepository.getAgentEndPoint(orgId); - if (!agentDetailsForAlloc?.orgDid) { - throw new BadRequestException('Organization DID is required for revocable credentials'); - } - for (const cred of oidcCredentialD2APayload.credentials) { - if (!cred.statusListDetails) { - try { - const allocation = await this.statusListAllocatorService.allocate(orgId, agentDetailsForAlloc.orgDid); - cred.statusListDetails = { - listId: allocation.listId, - index: allocation.index, - listSize: Number(CommonConstants.DEFAULT_STATUS_LIST_SIZE) - }; - } catch (allocError) { - this.logger.warn(`Could not allocate status list index: ${allocError.message}`); + const hasSdJwt = oidcCredentialD2APayload.credentials.some((c) => c.format === CredentialFormat.SdJwtVc); + if (hasSdJwt) { + const agentDetailsForAlloc = await this.oid4vcIssuanceRepository.getAgentEndPoint(orgId); + if (!agentDetailsForAlloc?.orgDid) { + throw new BadRequestException('Organization DID is required for revocable credentials'); + } + for (const cred of oidcCredentialD2APayload.credentials) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if ((cred as any).format === CredentialFormat.SdJwtVc && !cred.statusListDetails) { + try { + const allocation = await this.statusListAllocatorService.allocate(orgId, agentDetailsForAlloc.orgDid); + cred.statusListDetails = { + listId: allocation.listId, + index: allocation.index, + listSize: Number(CommonConstants.DEFAULT_STATUS_LIST_SIZE) + }; + } catch (allocError) { + this.logger.warn(`Could not allocate status list index: ${allocError.message}`); + } } } } From 3eef6d36859f05f1b582a6178e8189d68e752ef1 Mon Sep 17 00:00:00 2001 From: Sagar Khole Date: Thu, 26 Mar 2026 13:41:42 +0530 Subject: [PATCH 07/18] feat: Implement status list allocation for OID4VC credential issuance Signed-off-by: Sagar Khole --- .../src/agent-service.controller.ts | 5 + .../src/agent-service.service.ts | 13 ++ .../oid4vc-issuance.controller.ts | 24 +++ .../oid4vc-issuance.service.ts | 5 + .../src/oid4vc-issuance.controller.ts | 13 +- .../src/oid4vc-issuance.module.ts | 10 +- .../src/oid4vc-issuance.service.ts | 181 ++++++++++++++-- .../src/status-list-allocator.service.ts | 194 ++++++++++++++++++ libs/common/src/common.constant.ts | 1 + .../migration.sql | 44 ++++ libs/prisma-service/prisma/schema.prisma | 29 +++ 11 files changed, 500 insertions(+), 19 deletions(-) create mode 100644 apps/oid4vc-issuance/src/status-list-allocator.service.ts create mode 100644 libs/prisma-service/prisma/migrations/20260323183000_index_allocation/migration.sql diff --git a/apps/agent-service/src/agent-service.controller.ts b/apps/agent-service/src/agent-service.controller.ts index 9024f06e85..8faf3f5b7c 100644 --- a/apps/agent-service/src/agent-service.controller.ts +++ b/apps/agent-service/src/agent-service.controller.ts @@ -390,6 +390,11 @@ export class AgentServiceController { return this.agentServiceService.oidcDeleteCredentialOffer(payload.url, payload.orgId); } + @MessagePattern({ cmd: 'agent-service-oid4vc-revoke-credential' }) + async oidcRevokeCredential(payload: { url: string; orgId: string; statusListDetails?: object }): Promise { + return this.agentServiceService.oidcRevokeCredential(payload.url, payload.orgId, payload.statusListDetails); + } + @MessagePattern({ cmd: 'agent-create-x509-certificate' }) async createX509Certificate(payload: { options: X509CreateCertificateOptions; diff --git a/apps/agent-service/src/agent-service.service.ts b/apps/agent-service/src/agent-service.service.ts index 19aec908bc..f5d47311f2 100644 --- a/apps/agent-service/src/agent-service.service.ts +++ b/apps/agent-service/src/agent-service.service.ts @@ -1489,6 +1489,19 @@ export class AgentServiceService { } } + async oidcRevokeCredential(url: string, orgId: string, statusListDetails?: object): Promise { + try { + const getApiKey = await this.getOrgAgentApiKey(orgId); + const data = await this.commonService + .httpPost(`${url}`, statusListDetails || {}, { headers: { authorization: getApiKey } }) + .then(async (response) => response); + return data; + } catch (error) { + this.logger.error(`Error in _oidcRevokeCredential in agent service : ${JSON.stringify(error)}`); + throw error; + } + } + async oidcIssuerTemplate(templatePayload, url: string, orgId: string): Promise { try { const getApiKey = await this.getOrgAgentApiKey(orgId); diff --git a/apps/api-gateway/src/oid4vc-issuance/oid4vc-issuance.controller.ts b/apps/api-gateway/src/oid4vc-issuance/oid4vc-issuance.controller.ts index 41ceec7973..cf44a18994 100644 --- a/apps/api-gateway/src/oid4vc-issuance/oid4vc-issuance.controller.ts +++ b/apps/api-gateway/src/oid4vc-issuance/oid4vc-issuance.controller.ts @@ -672,4 +672,28 @@ export class Oid4vcIssuanceController { return res.status(HttpStatus.CREATED).json(finalResponse); } + + @Post('/orgs/:orgId/openid4vc/issuance-sessions/:issuanceSessionId/revoke') + @ApiOperation({ + summary: 'Revoke an OID4VC Credential', + description: 'Instantly revokes a credential issued during a specific OID4VC session.' + }) + @ApiResponse({ status: HttpStatus.OK, description: 'Credential revoked successfully.', type: ApiResponseDto }) + @ApiBearerAuth() + @Roles(OrgRoles.OWNER) + @UseGuards(AuthGuard('jwt'), OrgRolesGuard) + async revokeCredential( + @Param('orgId') orgId: string, + @Param('issuanceSessionId') issuanceSessionId: string, + @Res() res: Response + ): Promise { + const revoked = await this.oid4vcIssuanceService.revokeCredential(issuanceSessionId, orgId); + + const finalResponse: IResponse = { + statusCode: HttpStatus.OK, + message: 'Credential revoked successfully.', + data: revoked + }; + return res.status(HttpStatus.OK).json(finalResponse); + } } diff --git a/apps/api-gateway/src/oid4vc-issuance/oid4vc-issuance.service.ts b/apps/api-gateway/src/oid4vc-issuance/oid4vc-issuance.service.ts index 8a90ec4d23..45a14ba837 100644 --- a/apps/api-gateway/src/oid4vc-issuance/oid4vc-issuance.service.ts +++ b/apps/api-gateway/src/oid4vc-issuance/oid4vc-issuance.service.ts @@ -131,6 +131,11 @@ export class Oid4vcIssuanceService extends BaseService { return this.natsClient.sendNatsMessage(this.issuanceProxy, 'oid4vc-credential-offer-delete', payload); } + async revokeCredential(issuanceSessionId: string, orgId: string): Promise { + const payload = { issuanceSessionId, orgId }; + return this.natsClient.sendNatsMessage(this.issuanceProxy, 'oid4vc-revoke-credential', payload); + } + oidcIssueCredentialWebhook( oidcIssueCredentialDto: OidcIssueCredentialDto, id: string diff --git a/apps/oid4vc-issuance/src/oid4vc-issuance.controller.ts b/apps/oid4vc-issuance/src/oid4vc-issuance.controller.ts index b1e471fc37..0d31ae7472 100644 --- a/apps/oid4vc-issuance/src/oid4vc-issuance.controller.ts +++ b/apps/oid4vc-issuance/src/oid4vc-issuance.controller.ts @@ -165,16 +165,17 @@ export class Oid4vcIssuanceController { } @MessagePattern({ cmd: 'oid4vc-credential-offer-delete' }) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - async deleteCredentialOffers(payload: { - orgId: string; - credentialId: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - }): Promise { + async deleteCredentialOffers(payload: { orgId: string; credentialId: string }): Promise { const { orgId, credentialId } = payload; return this.oid4vcIssuanceService.deleteCredentialOffers(orgId, credentialId); } + @MessagePattern({ cmd: 'oid4vc-revoke-credential' }) + async revokeCredential(payload: { issuanceSessionId: string; orgId: string }): Promise { + const { issuanceSessionId, orgId } = payload; + return this.oid4vcIssuanceService.revokeCredential(issuanceSessionId, orgId); + } + @MessagePattern({ cmd: 'webhook-oid4vc-issue-credential' }) async oidcIssueCredentialWebhook(payload: Oid4vcCredentialOfferWebhookPayload): Promise { return this.oid4vcIssuanceService.storeOidcCredentialWebhook(payload); diff --git a/apps/oid4vc-issuance/src/oid4vc-issuance.module.ts b/apps/oid4vc-issuance/src/oid4vc-issuance.module.ts index f9e0fd194e..61cb23a238 100644 --- a/apps/oid4vc-issuance/src/oid4vc-issuance.module.ts +++ b/apps/oid4vc-issuance/src/oid4vc-issuance.module.ts @@ -13,6 +13,7 @@ import { ConfigModule as PlatformConfig } from '@credebl/config/config.module'; import { Oid4vcIssuanceRepository } from './oid4vc-issuance.repository'; import { NATSClient } from '@credebl/common/NATSClient'; import { PrismaService } from '@credebl/prisma-service'; +import { StatusListAllocatorService } from './status-list-allocator.service'; @Module({ imports: [ @@ -35,6 +36,13 @@ import { PrismaService } from '@credebl/prisma-service'; CacheModule.register() ], controllers: [Oid4vcIssuanceController], - providers: [Oid4vcIssuanceService, Oid4vcIssuanceRepository, PrismaService, Logger, NATSClient] + providers: [ + Oid4vcIssuanceService, + Oid4vcIssuanceRepository, + PrismaService, + Logger, + NATSClient, + StatusListAllocatorService + ] }) export class Oid4vcIssuanceModule {} diff --git a/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts b/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts index 2240a53a01..19ded493cf 100644 --- a/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts +++ b/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts @@ -10,6 +10,7 @@ import { BadRequestException, ConflictException, HttpException, + HttpStatus, Inject, Injectable, InternalServerErrorException, @@ -18,6 +19,8 @@ import { Scope } from '@nestjs/common'; import { Oid4vcIssuanceRepository } from './oid4vc-issuance.repository'; +import { StatusListAllocatorService } from './status-list-allocator.service'; +import { CredentialFormat } from '@credebl/enum/enum'; import { CommonConstants } from '@credebl/common/common.constant'; import { ResponseMessages } from '@credebl/common/response-messages'; import { ClientProxy, RpcException } from '@nestjs/microservices'; @@ -79,7 +82,8 @@ export class Oid4vcIssuanceService { private readonly logger = new Logger('IssueCredentialService'); constructor( @Inject('NATS_CLIENT') private readonly issuanceServiceProxy: ClientProxy, - private readonly oid4vcIssuanceRepository: Oid4vcIssuanceRepository + private readonly oid4vcIssuanceRepository: Oid4vcIssuanceRepository, + private readonly statusListAllocatorService: StatusListAllocatorService ) {} async oidcIssuerCreate(issuerCreation: IssuerCreation, orgId: string, userDetails: user): Promise { @@ -528,6 +532,7 @@ export class Oid4vcIssuanceService { userDetails: user, issuerId: string ): Promise { + let buildOidcCredentialOffer: CredentialOfferPayload; try { const filterTemplateIds = extractTemplateIds(createOidcCredentialOffer); if (!filterTemplateIds) { @@ -598,7 +603,7 @@ export class Oid4vcIssuanceService { //TODO: add logic to pass the issuer info const issuerDetailsFromDb = await this.oid4vcIssuanceRepository.getOidcIssuerDetailsById(issuerId); const { publicIssuerId, authorizationServerUrl } = issuerDetailsFromDb || {}; - const buildOidcCredentialOffer: CredentialOfferPayload = buildCredentialOfferPayload( + buildOidcCredentialOffer = buildCredentialOfferPayload( createOidcCredentialOffer, // getAllOfferTemplates, @@ -623,12 +628,25 @@ export class Oid4vcIssuanceService { CommonConstants.OIDC_ISSUER_SESSIONS_CREDENTIAL_OFFER, issuerDetails.publicIssuerId ); - this.logger.debug(`Creating OIDC Credential Offer for :`, buildOidcCredentialOffer); + + const agentDetailsForAlloc = await this.oid4vcIssuanceRepository.getAgentEndPoint(orgId); + for (const cred of buildOidcCredentialOffer.credentials) { + if (cred.format === CredentialFormat.SdJwtVc || cred.format === CredentialFormat.Mdoc) { + const allocation = await this.statusListAllocatorService.allocate(orgId, agentDetailsForAlloc.orgDid); + (cred as any).statusListDetails = { + listId: allocation.listId, + index: allocation.index, + listSize: CommonConstants.DEFAULT_STATUS_LIST_SIZE + }; + } + } + const createCredentialOfferOnAgent = await this._oidcCreateCredentialOffer(buildOidcCredentialOffer, url, orgId); if (!createCredentialOfferOnAgent) { throw new NotFoundException(ResponseMessages.oidcIssuerSession.error.errorCreateOffer); } - + + // Logic to add noticeUrl in response from agent if it is present in template or request payload const { response } = createCredentialOfferOnAgent; if (null !== response) { @@ -644,8 +662,48 @@ export class Oid4vcIssuanceService { } } - return response; + // Revocation logic to save the credential offer id and status list details in DB for later use during revocation + let parsedResponse; + if ('string' === typeof createCredentialOfferOnAgent.response) { + parsedResponse = JSON.parse(createCredentialOfferOnAgent.response); + } else { + parsedResponse = createCredentialOfferOnAgent.response; + } + + const issuanceSessionId = + parsedResponse.issuanceSessionId || parsedResponse.credentialOfferId || parsedResponse.id; + if (issuanceSessionId) { + for (const cred of buildOidcCredentialOffer.credentials) { + if ((cred as any).statusListDetails) { + const statusListUri = `${process.env.STATUS_LIST_HOST}/status-lists/${(cred as any).statusListDetails.listId}`; + await this.statusListAllocatorService.saveCredentialAllocation( + String(cred.credentialSupportedId), + (cred as any).statusListDetails.listId, + (cred as any).statusListDetails.index, + issuanceSessionId, + statusListUri + ); + } + } + } + + return createCredentialOfferOnAgent.response; } catch (error) { + if (buildOidcCredentialOffer?.credentials) { + for (const cred of buildOidcCredentialOffer.credentials) { + if ((cred as any).statusListDetails) { + try { + await this.statusListAllocatorService.release( + (cred as any).statusListDetails.listId, + (cred as any).statusListDetails.index + ); + } catch (releaseErr) { + this.logger.warn(`Failed to release index on rollback: ${releaseErr.message}`); + } + } + } + } + const errorResponse = ErrorHandler.categorize(error, 'Failed to create credential offer'); this.logger.error( `[createOidcCredentialOffer] - ${errorResponse.statusCode}: ${errorResponse.message}`, @@ -694,13 +752,69 @@ export class Oid4vcIssuanceService { await this.getAgentEndpoint(orgId), CommonConstants.OIDC_ISSUER_SESSIONS_CREDENTIAL_OFFER ); + + const agentDetailsForAlloc = await this.oid4vcIssuanceRepository.getAgentEndPoint(orgId); + for (const cred of oidcCredentialD2APayload.credentials) { + if (!cred.statusListDetails) { + try { + const allocation = await this.statusListAllocatorService.allocate(orgId, agentDetailsForAlloc.orgDid); + cred.statusListDetails = { + listId: allocation.listId, + index: allocation.index, + listSize: CommonConstants.DEFAULT_STATUS_LIST_SIZE + }; + } catch (allocError) { + this.logger.warn(`Could not allocate status list index: ${allocError.message}`); + } + } + } + const createCredentialOfferOnAgent = await this._oidcCreateCredentialOffer(oidcCredentialD2APayload, url, orgId); if (!createCredentialOfferOnAgent) { throw new NotFoundException(ResponseMessages.oidcIssuerSession.error.errorCreateOffer); } + // Save allocations now that we have the session id + let parsedResponse; + if ('string' === typeof createCredentialOfferOnAgent.response) { + parsedResponse = JSON.parse(createCredentialOfferOnAgent.response); + } else { + parsedResponse = createCredentialOfferOnAgent.response; + } + + const issuanceSessionId = + parsedResponse.issuanceSessionId || parsedResponse.credentialOfferId || parsedResponse.id; + if (issuanceSessionId) { + for (const cred of oidcCredentialD2APayload.credentials) { + if (cred.statusListDetails) { + const statusListUri = `${process.env.STATUS_LIST_HOST || 'http://10.0.0.77:3000'}/status-lists/${cred.statusListDetails.listId}`; + await this.statusListAllocatorService.saveCredentialAllocation( + String(cred.credentialSupportedId || cred.id), + cred.statusListDetails.listId, + cred.statusListDetails.index, + issuanceSessionId, + statusListUri + ); + } + } + } + return createCredentialOfferOnAgent.response; } catch (error) { + if (oidcCredentialD2APayload?.credentials) { + for (const cred of oidcCredentialD2APayload.credentials) { + if (cred.statusListDetails) { + try { + await this.statusListAllocatorService.release( + cred.statusListDetails.listId, + cred.statusListDetails.index + ); + } catch (releaseErr) { + this.logger.warn(`Failed to release index on rollback: ${releaseErr.message}`); + } + } + } + } this.logger.error(`[createOidcCredentialOffer] - error: ${JSON.stringify(error)}`); throw new RpcException(error.response ?? error); } @@ -782,7 +896,50 @@ export class Oid4vcIssuanceService { } return deletedCredentialOffer.response; } catch (error) { - this.logger.error(`[getCredentialOffers] - error: ${JSON.stringify(error)}`); + this.logger.error(`[deleteCredentialOffers] - error: ${JSON.stringify(error)}`); + throw new RpcException(error.response ?? error); + } + } + + async revokeCredential(issuanceSessionId: string, orgId: string): Promise { + try { + if (!issuanceSessionId) { + throw new BadRequestException('Please provide a valid issuanceSessionId'); + } + + const allocations = await this.statusListAllocatorService.getCredentialAllocations(issuanceSessionId); + + const url = getAgentUrl( + await this.getAgentEndpoint(orgId), + CommonConstants.OIDC_ISSUER_SESSIONS_BY_ID, + `${issuanceSessionId}/revoke` + ); + + const pattern = { cmd: 'agent-service-oid4vc-revoke-credential' }; + + let lastResponse; + if (allocations && 0 < allocations.length) { + for (const allocation of allocations) { + const payload: any = { + url, + orgId, + statusListDetails: { + listId: allocation.listId, + index: allocation.index, + listSize: CommonConstants.DEFAULT_STATUS_LIST_SIZE + } + }; + lastResponse = await this.natsCall(pattern, payload); + } + } else { + // Fallback for cases where no status list allocations exist + const payload: any = { url, orgId }; + lastResponse = await this.natsCall(pattern, payload); + } + + return lastResponse; + } catch (error) { + this.logger.error(`[revokeCredential] - error: ${JSON.stringify(error)}`); throw new RpcException(error.response ?? error); } } @@ -959,10 +1116,10 @@ export class Oid4vcIssuanceService { this.logger.error(`catch: ${JSON.stringify(error)}`); throw new HttpException( { - status: error.statusCode, + status: error.statusCode || HttpStatus.INTERNAL_SERVER_ERROR, error: error.message }, - error.error + error.statusCode || error.status || HttpStatus.INTERNAL_SERVER_ERROR ); }); } catch (error) { @@ -990,16 +1147,16 @@ export class Oid4vcIssuanceService { ): Promise { try { // pick fields - let organisationId: string; const { oidcIssueCredentialDto, id } = CredentialOfferWebhookPayload; + let organisationId = id; if ('default' !== oidcIssueCredentialDto?.contextCorrelationId) { const getOrganizationId = await this.oid4vcIssuanceRepository.getOrganizationByTenantId( oidcIssueCredentialDto?.contextCorrelationId ); - organisationId = getOrganizationId?.orgId; - } else { - organisationId = id; + if (getOrganizationId?.orgId) { + organisationId = getOrganizationId.orgId; + } } const { diff --git a/apps/oid4vc-issuance/src/status-list-allocator.service.ts b/apps/oid4vc-issuance/src/status-list-allocator.service.ts new file mode 100644 index 0000000000..fbdae296db --- /dev/null +++ b/apps/oid4vc-issuance/src/status-list-allocator.service.ts @@ -0,0 +1,194 @@ +/* eslint-disable camelcase */ +import { Injectable, Logger } from '@nestjs/common'; +import { PrismaService } from '@credebl/prisma-service'; +import { issued_oid4vc_credentials } from '@prisma/client'; +import { CommonConstants } from '@credebl/common/common.constant'; +import { randomInt, randomUUID } from 'crypto'; + +export class RandomBitmapIndexAllocator { + private bitmap: Uint8Array; + private capacity: number; + private allocatedCount: number; + + constructor(capacity: number, existing?: Uint8Array) { + this.capacity = capacity; + this.bitmap = existing ? new Uint8Array(existing) : new Uint8Array(Math.ceil(capacity / 8)); + + this.allocatedCount = 0; + + for (const byte of this.bitmap) { + if (0 !== byte) { + this.allocatedCount += byte.toString(2).split('1').length - 1; + } + } + } + + private isSet(index: number): boolean { + const byteIndex = index >> 3; + const bit = index & 7; + return 0 !== (this.bitmap[byteIndex] & (1 << bit)); + } + + private set(index: number): void { + const byteIndex = index >> 3; + const bit = index & 7; + this.bitmap[byteIndex] |= 1 << bit; + } + + private clear(index: number): void { + const byteIndex = index >> 3; + const bit = index & 7; + this.bitmap[byteIndex] &= ~(1 << bit); + } + + public allocate(): number { + if (this.allocatedCount === this.capacity) { + throw new Error('No indexes left'); + } + + while (true) { + const idx = randomInt(this.capacity); + + if (!this.isSet(idx)) { + this.set(idx); + this.allocatedCount++; + return idx; + } + } + } + + public isIndexAllocated(index: number): boolean { + if (0 > index || index >= this.capacity) { + throw new Error('Invalid index'); + } + return this.isSet(index); + } + + public release(index: number): void { + if (0 > index || index >= this.capacity) { + throw new Error('Invalid index'); + } + + if (this.isSet(index)) { + this.clear(index); + this.allocatedCount--; + } + } + + public export(): Uint8Array { + return new Uint8Array(this.bitmap); + } +} + +@Injectable() +export class StatusListAllocatorService { + private readonly logger = new Logger(StatusListAllocatorService.name); + + constructor(private readonly prisma: PrismaService) {} + + async allocate(orgId: string, issuerDid: string, listSize?: number): Promise<{ listId: string; index: number }> { + const defaultListSize = CommonConstants.DEFAULT_STATUS_LIST_SIZE; + return this.prisma.$transaction(async (tx) => { + // Find active list or create one + let activeList = await tx.status_list_allocation.findFirst({ + where: { orgId, issuerDid, isActive: true } + }); + + if (!activeList || activeList.allocatedCount >= activeList.listSize) { + // Mark inactive if full + if (activeList) { + await tx.status_list_allocation.update({ + where: { id: activeList.id }, + data: { isActive: false } + }); + } + + activeList = await tx.status_list_allocation.create({ + data: { + orgId, + issuerDid, + listId: randomUUID(), + listSize: listSize || defaultListSize, + allocatedCount: 0, + bitmap: Buffer.from(new Uint8Array(Math.ceil((listSize || defaultListSize) / 8))), + isActive: true + } + }); + } + + const allocator = new RandomBitmapIndexAllocator(activeList.listSize, new Uint8Array(activeList.bitmap)); + + try { + const index = allocator.allocate(); + + // Update database + await tx.status_list_allocation.update({ + where: { id: activeList.id }, + data: { + bitmap: Buffer.from(allocator.export()), + allocatedCount: activeList.allocatedCount + 1 + } + }); + + return { listId: activeList.listId, index }; + } catch (error) { + if ('No indexes left' === error.message) { + // Retry rotation if we hit the race condition limit + await tx.status_list_allocation.update({ + where: { id: activeList.id }, + data: { isActive: false } + }); + throw new Error('Retry allocation, list full'); + } + throw error; + } + }); + } + + async saveCredentialAllocation( + credentialId: string, + listId: string, + index: number, + issuanceSessionId: string, + statusListUri: string + ): Promise { + await this.prisma.issued_oid4vc_credentials.create({ + data: { + credentialId, + listId, + index, + issuanceSessionId, + statusListUri + } + }); + } + + async getCredentialAllocations(issuanceSessionId: string): Promise { + return this.prisma.issued_oid4vc_credentials.findMany({ + where: { issuanceSessionId } + }); + } + + async release(listId: string, index: number): Promise { + await this.prisma.$transaction(async (tx) => { + const allocation = await tx.status_list_allocation.findUnique({ + where: { listId } + }); + + if (!allocation) { + return; + } + + const allocator = new RandomBitmapIndexAllocator(allocation.listSize, new Uint8Array(allocation.bitmap)); + allocator.release(index); + + await tx.status_list_allocation.update({ + where: { listId }, + data: { + bitmap: Buffer.from(allocator.export()), + allocatedCount: Math.max(0, allocation.allocatedCount - 1) + } + }); + }); + } +} diff --git a/libs/common/src/common.constant.ts b/libs/common/src/common.constant.ts index 0b2531284d..99d23bb4dd 100644 --- a/libs/common/src/common.constant.ts +++ b/libs/common/src/common.constant.ts @@ -229,6 +229,7 @@ export enum CommonConstants { // eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values DEFAULT_CACHE_TTL = 60000, + DEFAULT_STATUS_LIST_SIZE = 131072, DEFAULT_FIELD_UPLOAD_SIZE = 10485760, // SUBSCRIPTION TYPES diff --git a/libs/prisma-service/prisma/migrations/20260323183000_index_allocation/migration.sql b/libs/prisma-service/prisma/migrations/20260323183000_index_allocation/migration.sql new file mode 100644 index 0000000000..c4e22f74bd --- /dev/null +++ b/libs/prisma-service/prisma/migrations/20260323183000_index_allocation/migration.sql @@ -0,0 +1,44 @@ +-- CreateTable +CREATE TABLE "status_list_allocation" ( + "id" UUID NOT NULL, + "orgId" UUID NOT NULL, + "issuerDid" TEXT NOT NULL, + "listId" UUID NOT NULL, + "listSize" INTEGER NOT NULL DEFAULT 131072, + "allocatedCount" INTEGER NOT NULL DEFAULT 0, + "bitmap" BYTEA NOT NULL, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createDateTime" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "status_list_allocation_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "issued_oid4vc_credentials" ( + "id" UUID NOT NULL, + "credentialId" TEXT NOT NULL, + "listId" UUID NOT NULL, + "index" INTEGER NOT NULL, + "issuanceSessionId" VARCHAR NOT NULL, + "createDateTime" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "statusListUri" TEXT NOT NULL, + + CONSTRAINT "issued_oid4vc_credentials_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "status_list_allocation_listId_key" ON "status_list_allocation"("listId"); + +-- CreateIndex +CREATE INDEX "status_list_allocation_orgId_isActive_idx" ON "status_list_allocation"("orgId", "isActive"); + +-- CreateIndex +CREATE UNIQUE INDEX "issued_oid4vc_credentials_credentialId_key" ON "issued_oid4vc_credentials"("credentialId"); + +-- AddForeignKey +ALTER TABLE "status_list_allocation" ADD CONSTRAINT "status_list_allocation_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "organisation"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- Add webhookSecret safely +ALTER TABLE "org_agents" ADD COLUMN IF NOT EXISTS "webhookSecret" VARCHAR; diff --git a/libs/prisma-service/prisma/schema.prisma b/libs/prisma-service/prisma/schema.prisma index 13421aa84e..7d3d8da69f 100755 --- a/libs/prisma-service/prisma/schema.prisma +++ b/libs/prisma-service/prisma/schema.prisma @@ -160,6 +160,7 @@ model organisation { intent_notices intent_notices[] ecosystemOrgs ecosystem_orgs[] ecosystem_invitations ecosystem_invitations[] + status_list_allocations status_list_allocation[] } model org_invitations { @@ -231,6 +232,7 @@ model org_agents { orgAgentTypeId String? @db.Uuid ledgerId String? @db.Uuid didDocument Json? + webhookSecret String? @db.VarChar agent_invitations agent_invitations[] agents agents? @relation(fields: [agentId], references: [id]) agents_type agents_type? @relation(fields: [agentsTypeId], references: [id]) @@ -632,6 +634,33 @@ model oid4vp_presentations { organisation organisation @relation(fields: [orgId], references: [id]) } +model status_list_allocation { + id String @id @default(uuid()) @db.Uuid + orgId String @db.Uuid + issuerDid String + listId String @unique @db.Uuid + listSize Int @default(131072) + allocatedCount Int @default(0) + bitmap Bytes + isActive Boolean @default(true) + createDateTime DateTime @default(now()) @db.Timestamptz(6) + updatedAt DateTime @updatedAt + organisation organisation @relation(fields: [orgId], references: [id]) + + @@index([orgId, isActive]) +} + +model issued_oid4vc_credentials { + id String @id @default(uuid()) @db.Uuid + credentialId String @unique + listId String @db.Uuid + index Int + issuanceSessionId String @db.VarChar + createDateTime DateTime @default(now()) @db.Timestamptz(6) + updatedAt DateTime @updatedAt + statusListUri String +} + enum SignerOption { DID X509_P256 From a9b4613f570ffa17df7b5d8f3117c58d77c60067 Mon Sep 17 00:00:00 2001 From: Sagar Khole Date: Thu, 26 Mar 2026 15:24:25 +0530 Subject: [PATCH 08/18] feat: Implement status list allocation for OID4VC credential issuance, Signed-off-by: Sagar Khole --- apps/oid4vc-issuance/src/main.ts | 5 +++++ apps/oid4vc-issuance/src/oid4vc-issuance.service.ts | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/oid4vc-issuance/src/main.ts b/apps/oid4vc-issuance/src/main.ts index 9f968ed7d7..9db3630bbf 100644 --- a/apps/oid4vc-issuance/src/main.ts +++ b/apps/oid4vc-issuance/src/main.ts @@ -10,6 +10,11 @@ import { Oid4vcIssuanceModule } from './oid4vc-issuance.module'; const logger = new Logger(); async function bootstrap(): Promise { + if (!process.env.STATUS_LIST_HOST) { + logger.error('STATUS_LIST_HOST is not configured. Microservice cannot start.'); + process.exit(1); + } + const app = await NestFactory.createMicroservice(Oid4vcIssuanceModule, { transport: Transport.NATS, options: getNatsOptions( diff --git a/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts b/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts index 19ded493cf..22ce54fedf 100644 --- a/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts +++ b/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts @@ -787,7 +787,7 @@ export class Oid4vcIssuanceService { if (issuanceSessionId) { for (const cred of oidcCredentialD2APayload.credentials) { if (cred.statusListDetails) { - const statusListUri = `${process.env.STATUS_LIST_HOST || 'http://10.0.0.77:3000'}/status-lists/${cred.statusListDetails.listId}`; + const statusListUri = `${process.env.STATUS_LIST_HOST}/status-lists/${cred.statusListDetails.listId}`; await this.statusListAllocatorService.saveCredentialAllocation( String(cred.credentialSupportedId || cred.id), cred.statusListDetails.listId, From 53be9b51c3225e940408f68126f00bd0f1645f3b Mon Sep 17 00:00:00 2001 From: Sagar Khole Date: Thu, 26 Mar 2026 15:26:49 +0530 Subject: [PATCH 09/18] feat: Implement status list allocation for OID4VC credential issuance, Signed-off-by: Sagar Khole --- .env.demo | 3 ++- .env.sample | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.env.demo b/.env.demo index a6ecbb083f..254a51fc50 100644 --- a/.env.demo +++ b/.env.demo @@ -252,4 +252,5 @@ NATS_AUTH_TYPE=nkey # 'nkey' | 'creds' | 'usernamePassword' | 'none' NOTIFICATION_NATS_AUTH_TYPE= -ENABLE_NATS_NOTIFICATION=false \ No newline at end of file +ENABLE_NATS_NOTIFICATION=false +STATUS_LIST_HOST= diff --git a/.env.sample b/.env.sample index 243493ce17..5a0d16b1b9 100644 --- a/.env.sample +++ b/.env.sample @@ -280,4 +280,5 @@ NATS_AUTH_TYPE=nkey # 'nkey' | 'creds' | 'usernamePassword' | 'none' NOTIFICATION_NATS_AUTH_TYPE= -ENABLE_NATS_NOTIFICATION=false \ No newline at end of file +ENABLE_NATS_NOTIFICATION=false +STATUS_LIST_HOST= From 05e30f904388c0adc97ba8929eedf72dd659909b Mon Sep 17 00:00:00 2001 From: Sagar Khole Date: Thu, 26 Mar 2026 18:31:06 +0530 Subject: [PATCH 10/18] update OID4VC issuance logic Signed-off-by: Sagar Khole --- .../dtos/issuer-sessions.dto.ts | 10 ++- .../oid4vc-issuer-sessions.interfaces.ts | 1 + .../helpers/credential-sessions.builder.ts | 10 ++- .../src/oid4vc-issuance.service.ts | 74 ++++++++++--------- .../src/status-list-allocator.service.ts | 3 + 5 files changed, 63 insertions(+), 35 deletions(-) diff --git a/apps/api-gateway/src/oid4vc-issuance/dtos/issuer-sessions.dto.ts b/apps/api-gateway/src/oid4vc-issuance/dtos/issuer-sessions.dto.ts index 20f8e3eefa..5dcbd9fdde 100644 --- a/apps/api-gateway/src/oid4vc-issuance/dtos/issuer-sessions.dto.ts +++ b/apps/api-gateway/src/oid4vc-issuance/dtos/issuer-sessions.dto.ts @@ -195,6 +195,10 @@ export class CreateOidcCredentialOfferDto { noticeUrl?: string; issuerId?: string; + + @ApiPropertyOptional({ example: true, description: 'Flag to enable revocation for the issued credentials' }) + @IsOptional() + isRevocable?: boolean; } export class GetAllCredentialOfferDto { @@ -300,7 +304,7 @@ export class CredentialDto { }, iat: 1698151532, nbf: dateToSeconds(new Date()), - exp: dateToSeconds(new Date(Date.now() + 5 * 365 * 24 * 60 * 60 * 1000)) + exp: dateToSeconds(new Date(Date.now() + 157680000000)) } ] }) @@ -390,6 +394,10 @@ export class CreateCredentialOfferD2ADto { @IsOptional() issuerId?: string; + @ApiPropertyOptional({ example: true, description: 'Flag to enable revocation for the issued credentials' }) + @IsOptional() + isRevocable?: boolean; + @ExactlyOneOf(['preAuthorizedCodeFlowConfig', 'authorizationCodeFlowConfig'], { message: 'Provide exactly one of preAuthorizedCodeFlowConfig or authorizationCodeFlowConfig.' }) diff --git a/apps/oid4vc-issuance/interfaces/oid4vc-issuer-sessions.interfaces.ts b/apps/oid4vc-issuance/interfaces/oid4vc-issuer-sessions.interfaces.ts index 1bc4785f94..df6d4c00c0 100644 --- a/apps/oid4vc-issuance/interfaces/oid4vc-issuer-sessions.interfaces.ts +++ b/apps/oid4vc-issuance/interfaces/oid4vc-issuer-sessions.interfaces.ts @@ -48,6 +48,7 @@ export interface CreateOidcCredentialOffer { authenticationType: AuthenticationType; // only option selector credentials: CredentialRequest[]; // one or more credentials noticeUrl?: string; + isRevocable?: boolean; } export interface GetAllCredentialOffer { diff --git a/apps/oid4vc-issuance/libs/helpers/credential-sessions.builder.ts b/apps/oid4vc-issuance/libs/helpers/credential-sessions.builder.ts index a2b8b208da..a76086122f 100644 --- a/apps/oid4vc-issuance/libs/helpers/credential-sessions.builder.ts +++ b/apps/oid4vc-issuance/libs/helpers/credential-sessions.builder.ts @@ -58,6 +58,7 @@ export interface CreateOidcCredentialOfferDtoLike { authorizationServerUrl: string; }; publicIssuerId?: string; + isRevocable?: boolean; } export interface ResolvedSignerOption { @@ -84,12 +85,18 @@ export interface BuiltCredential { format: CredentialFormat; payload: Record; disclosureFrame?: DisclosureFrame; + statusListDetails?: { + listId: string; + index: number; + listSize?: number; + }; } export interface BuiltCredentialOfferBase { signerOption?: ResolvedSignerOption; credentials: BuiltCredential[]; publicIssuerId?: string; + isRevocable?: boolean; } export type CredentialOfferPayload = BuiltCredentialOfferBase & @@ -477,7 +484,8 @@ export function buildCredentialOfferPayload( const baseEnvelope: BuiltCredentialOfferBase = { credentials: builtCredentials, - ...(finalPublicIssuerId ? { publicIssuerId: finalPublicIssuerId } : {}) + ...(finalPublicIssuerId ? { publicIssuerId: finalPublicIssuerId } : {}), + isRevocable: dto.isRevocable }; // Determine which authorization flow to return: diff --git a/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts b/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts index 22ce54fedf..82cf262cf7 100644 --- a/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts +++ b/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts @@ -629,15 +629,20 @@ export class Oid4vcIssuanceService { issuerDetails.publicIssuerId ); - const agentDetailsForAlloc = await this.oid4vcIssuanceRepository.getAgentEndPoint(orgId); - for (const cred of buildOidcCredentialOffer.credentials) { - if (cred.format === CredentialFormat.SdJwtVc || cred.format === CredentialFormat.Mdoc) { - const allocation = await this.statusListAllocatorService.allocate(orgId, agentDetailsForAlloc.orgDid); - (cred as any).statusListDetails = { - listId: allocation.listId, - index: allocation.index, - listSize: CommonConstants.DEFAULT_STATUS_LIST_SIZE - }; + if (createOidcCredentialOffer.isRevocable) { + const agentDetailsForAlloc = await this.oid4vcIssuanceRepository.getAgentEndPoint(orgId); + if (!agentDetailsForAlloc?.orgDid) { + throw new BadRequestException('Organization DID is required for revocable credentials'); + } + for (const cred of buildOidcCredentialOffer.credentials) { + if (cred.format === CredentialFormat.SdJwtVc || cred.format === CredentialFormat.Mdoc) { + const allocation = await this.statusListAllocatorService.allocate(orgId, agentDetailsForAlloc.orgDid); + cred.statusListDetails = { + listId: allocation.listId, + index: allocation.index, + listSize: CommonConstants.DEFAULT_STATUS_LIST_SIZE + }; + } } } @@ -672,14 +677,14 @@ export class Oid4vcIssuanceService { const issuanceSessionId = parsedResponse.issuanceSessionId || parsedResponse.credentialOfferId || parsedResponse.id; - if (issuanceSessionId) { + if (issuanceSessionId && createOidcCredentialOffer.isRevocable) { for (const cred of buildOidcCredentialOffer.credentials) { - if ((cred as any).statusListDetails) { - const statusListUri = `${process.env.STATUS_LIST_HOST}/status-lists/${(cred as any).statusListDetails.listId}`; + if (cred.statusListDetails) { + const statusListUri = `${process.env.STATUS_LIST_HOST}/status-lists/${cred.statusListDetails.listId}`; await this.statusListAllocatorService.saveCredentialAllocation( String(cred.credentialSupportedId), - (cred as any).statusListDetails.listId, - (cred as any).statusListDetails.index, + cred.statusListDetails.listId, + cred.statusListDetails.index, issuanceSessionId, statusListUri ); @@ -691,11 +696,11 @@ export class Oid4vcIssuanceService { } catch (error) { if (buildOidcCredentialOffer?.credentials) { for (const cred of buildOidcCredentialOffer.credentials) { - if ((cred as any).statusListDetails) { + if (cred.statusListDetails) { try { await this.statusListAllocatorService.release( - (cred as any).statusListDetails.listId, - (cred as any).statusListDetails.index + cred.statusListDetails.listId, + cred.statusListDetails.index ); } catch (releaseErr) { this.logger.warn(`Failed to release index on rollback: ${releaseErr.message}`); @@ -753,18 +758,23 @@ export class Oid4vcIssuanceService { CommonConstants.OIDC_ISSUER_SESSIONS_CREDENTIAL_OFFER ); - const agentDetailsForAlloc = await this.oid4vcIssuanceRepository.getAgentEndPoint(orgId); - for (const cred of oidcCredentialD2APayload.credentials) { - if (!cred.statusListDetails) { - try { - const allocation = await this.statusListAllocatorService.allocate(orgId, agentDetailsForAlloc.orgDid); - cred.statusListDetails = { - listId: allocation.listId, - index: allocation.index, - listSize: CommonConstants.DEFAULT_STATUS_LIST_SIZE - }; - } catch (allocError) { - this.logger.warn(`Could not allocate status list index: ${allocError.message}`); + if (oidcCredentialD2APayload.isRevocable) { + const agentDetailsForAlloc = await this.oid4vcIssuanceRepository.getAgentEndPoint(orgId); + if (!agentDetailsForAlloc?.orgDid) { + throw new BadRequestException('Organization DID is required for revocable credentials'); + } + for (const cred of oidcCredentialD2APayload.credentials) { + if (!cred.statusListDetails) { + try { + const allocation = await this.statusListAllocatorService.allocate(orgId, agentDetailsForAlloc.orgDid); + cred.statusListDetails = { + listId: allocation.listId, + index: allocation.index, + listSize: CommonConstants.DEFAULT_STATUS_LIST_SIZE + }; + } catch (allocError) { + this.logger.warn(`Could not allocate status list index: ${allocError.message}`); + } } } } @@ -784,7 +794,7 @@ export class Oid4vcIssuanceService { const issuanceSessionId = parsedResponse.issuanceSessionId || parsedResponse.credentialOfferId || parsedResponse.id; - if (issuanceSessionId) { + if (issuanceSessionId && oidcCredentialD2APayload.isRevocable) { for (const cred of oidcCredentialD2APayload.credentials) { if (cred.statusListDetails) { const statusListUri = `${process.env.STATUS_LIST_HOST}/status-lists/${cred.statusListDetails.listId}`; @@ -932,9 +942,7 @@ export class Oid4vcIssuanceService { lastResponse = await this.natsCall(pattern, payload); } } else { - // Fallback for cases where no status list allocations exist - const payload: any = { url, orgId }; - lastResponse = await this.natsCall(pattern, payload); + throw new BadRequestException('Credential is not revocable as no status list allocation was found.'); } return lastResponse; diff --git a/apps/oid4vc-issuance/src/status-list-allocator.service.ts b/apps/oid4vc-issuance/src/status-list-allocator.service.ts index fbdae296db..c44c81452c 100644 --- a/apps/oid4vc-issuance/src/status-list-allocator.service.ts +++ b/apps/oid4vc-issuance/src/status-list-allocator.service.ts @@ -87,6 +87,9 @@ export class StatusListAllocatorService { constructor(private readonly prisma: PrismaService) {} async allocate(orgId: string, issuerDid: string, listSize?: number): Promise<{ listId: string; index: number }> { + if (!orgId || !issuerDid) { + throw new Error('orgId and issuerDid are required for status list allocation'); + } const defaultListSize = CommonConstants.DEFAULT_STATUS_LIST_SIZE; return this.prisma.$transaction(async (tx) => { // Find active list or create one From 51d314b557642ad1a21122d8bb1473c2a4fc50c2 Mon Sep 17 00:00:00 2001 From: Sagar Khole Date: Fri, 27 Mar 2026 10:31:46 +0530 Subject: [PATCH 11/18] fix:sd-jwt revocation flow Signed-off-by: Sagar Khole --- .../src/oid4vc-issuance.service.ts | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts b/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts index 82cf262cf7..f205f4966c 100644 --- a/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts +++ b/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts @@ -640,7 +640,7 @@ export class Oid4vcIssuanceService { cred.statusListDetails = { listId: allocation.listId, index: allocation.index, - listSize: CommonConstants.DEFAULT_STATUS_LIST_SIZE + listSize: Number(CommonConstants.DEFAULT_STATUS_LIST_SIZE) }; } } @@ -676,13 +676,16 @@ export class Oid4vcIssuanceService { } const issuanceSessionId = - parsedResponse.issuanceSessionId || parsedResponse.credentialOfferId || parsedResponse.id; + parsedResponse.issuanceSessionId || + parsedResponse.credentialOfferId || + parsedResponse.id || + parsedResponse.issuanceSession?.id; if (issuanceSessionId && createOidcCredentialOffer.isRevocable) { for (const cred of buildOidcCredentialOffer.credentials) { if (cred.statusListDetails) { const statusListUri = `${process.env.STATUS_LIST_HOST}/status-lists/${cred.statusListDetails.listId}`; await this.statusListAllocatorService.saveCredentialAllocation( - String(cred.credentialSupportedId), + `${issuanceSessionId}-${cred.statusListDetails.index}`, cred.statusListDetails.listId, cred.statusListDetails.index, issuanceSessionId, @@ -770,7 +773,7 @@ export class Oid4vcIssuanceService { cred.statusListDetails = { listId: allocation.listId, index: allocation.index, - listSize: CommonConstants.DEFAULT_STATUS_LIST_SIZE + listSize: Number(CommonConstants.DEFAULT_STATUS_LIST_SIZE) }; } catch (allocError) { this.logger.warn(`Could not allocate status list index: ${allocError.message}`); @@ -793,13 +796,16 @@ export class Oid4vcIssuanceService { } const issuanceSessionId = - parsedResponse.issuanceSessionId || parsedResponse.credentialOfferId || parsedResponse.id; + parsedResponse.issuanceSessionId || + parsedResponse.credentialOfferId || + parsedResponse.id || + parsedResponse.issuanceSession?.id; if (issuanceSessionId && oidcCredentialD2APayload.isRevocable) { for (const cred of oidcCredentialD2APayload.credentials) { if (cred.statusListDetails) { const statusListUri = `${process.env.STATUS_LIST_HOST}/status-lists/${cred.statusListDetails.listId}`; await this.statusListAllocatorService.saveCredentialAllocation( - String(cred.credentialSupportedId || cred.id), + `${issuanceSessionId}-${cred.statusListDetails.index}`, cred.statusListDetails.listId, cred.statusListDetails.index, issuanceSessionId, @@ -936,7 +942,7 @@ export class Oid4vcIssuanceService { statusListDetails: { listId: allocation.listId, index: allocation.index, - listSize: CommonConstants.DEFAULT_STATUS_LIST_SIZE + listSize: Number(CommonConstants.DEFAULT_STATUS_LIST_SIZE) } }; lastResponse = await this.natsCall(pattern, payload); From fe14467e71439657401ea65748e4df723d577dc9 Mon Sep 17 00:00:00 2001 From: Sagar Khole Date: Fri, 27 Mar 2026 11:02:15 +0530 Subject: [PATCH 12/18] fix: restrict status list allocation to SD-JWT format only Signed-off-by: Sagar Khole --- .../src/oid4vc-issuance.service.ts | 61 +++++++++++-------- 1 file changed, 34 insertions(+), 27 deletions(-) diff --git a/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts b/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts index f205f4966c..4bbf759837 100644 --- a/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts +++ b/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts @@ -630,18 +630,21 @@ export class Oid4vcIssuanceService { ); if (createOidcCredentialOffer.isRevocable) { - const agentDetailsForAlloc = await this.oid4vcIssuanceRepository.getAgentEndPoint(orgId); - if (!agentDetailsForAlloc?.orgDid) { - throw new BadRequestException('Organization DID is required for revocable credentials'); - } - for (const cred of buildOidcCredentialOffer.credentials) { - if (cred.format === CredentialFormat.SdJwtVc || cred.format === CredentialFormat.Mdoc) { - const allocation = await this.statusListAllocatorService.allocate(orgId, agentDetailsForAlloc.orgDid); - cred.statusListDetails = { - listId: allocation.listId, - index: allocation.index, - listSize: Number(CommonConstants.DEFAULT_STATUS_LIST_SIZE) - }; + const hasSdJwt = buildOidcCredentialOffer.credentials.some((c) => c.format === CredentialFormat.SdJwtVc); + if (hasSdJwt) { + const agentDetailsForAlloc = await this.oid4vcIssuanceRepository.getAgentEndPoint(orgId); + if (!agentDetailsForAlloc?.orgDid) { + throw new BadRequestException('Organization DID is required for revocable SD-JWT credentials'); + } + for (const cred of buildOidcCredentialOffer.credentials) { + if (cred.format === CredentialFormat.SdJwtVc) { + const allocation = await this.statusListAllocatorService.allocate(orgId, agentDetailsForAlloc.orgDid); + cred.statusListDetails = { + listId: allocation.listId, + index: allocation.index, + listSize: Number(CommonConstants.DEFAULT_STATUS_LIST_SIZE) + }; + } } } } @@ -762,21 +765,25 @@ export class Oid4vcIssuanceService { ); if (oidcCredentialD2APayload.isRevocable) { - const agentDetailsForAlloc = await this.oid4vcIssuanceRepository.getAgentEndPoint(orgId); - if (!agentDetailsForAlloc?.orgDid) { - throw new BadRequestException('Organization DID is required for revocable credentials'); - } - for (const cred of oidcCredentialD2APayload.credentials) { - if (!cred.statusListDetails) { - try { - const allocation = await this.statusListAllocatorService.allocate(orgId, agentDetailsForAlloc.orgDid); - cred.statusListDetails = { - listId: allocation.listId, - index: allocation.index, - listSize: Number(CommonConstants.DEFAULT_STATUS_LIST_SIZE) - }; - } catch (allocError) { - this.logger.warn(`Could not allocate status list index: ${allocError.message}`); + const hasSdJwt = oidcCredentialD2APayload.credentials.some((c) => c.format === CredentialFormat.SdJwtVc); + if (hasSdJwt) { + const agentDetailsForAlloc = await this.oid4vcIssuanceRepository.getAgentEndPoint(orgId); + if (!agentDetailsForAlloc?.orgDid) { + throw new BadRequestException('Organization DID is required for revocable credentials'); + } + for (const cred of oidcCredentialD2APayload.credentials) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if ((cred as any).format === CredentialFormat.SdJwtVc && !cred.statusListDetails) { + try { + const allocation = await this.statusListAllocatorService.allocate(orgId, agentDetailsForAlloc.orgDid); + cred.statusListDetails = { + listId: allocation.listId, + index: allocation.index, + listSize: Number(CommonConstants.DEFAULT_STATUS_LIST_SIZE) + }; + } catch (allocError) { + this.logger.warn(`Could not allocate status list index: ${allocError.message}`); + } } } } From 379fc76caed6b2fc89a7a7ec20858a093a992e9a Mon Sep 17 00:00:00 2001 From: Tipu_Singh Date: Wed, 1 Apr 2026 14:18:03 +0530 Subject: [PATCH 13/18] fix: removed duplicate webhookSecret column for org agent Signed-off-by: Tipu_Singh --- libs/prisma-service/prisma/schema.prisma | 1 - 1 file changed, 1 deletion(-) diff --git a/libs/prisma-service/prisma/schema.prisma b/libs/prisma-service/prisma/schema.prisma index 7d3d8da69f..db4917b090 100755 --- a/libs/prisma-service/prisma/schema.prisma +++ b/libs/prisma-service/prisma/schema.prisma @@ -240,7 +240,6 @@ model org_agents { org_agent_type org_agents_type? @relation(fields: [orgAgentTypeId], references: [id]) organisation organisation? @relation(fields: [orgId], references: [id]) webhookUrl String? @db.VarChar - webhookSecret String? @db.VarChar org_dids org_dids[] oidc_issuer oidc_issuer[] x509_certificates x509_certificates[] From 7ba2bf786a5cf32172654a99b4033dfca537f246 Mon Sep 17 00:00:00 2001 From: Tipu_Singh Date: Mon, 6 Apr 2026 10:37:07 +0530 Subject: [PATCH 14/18] fix: discloser frame issue for sdjwt Signed-off-by: Tipu_Singh --- .../helpers/credential-sessions.builder.ts | 51 ++++++++++++------- 1 file changed, 32 insertions(+), 19 deletions(-) diff --git a/apps/oid4vc-issuance/libs/helpers/credential-sessions.builder.ts b/apps/oid4vc-issuance/libs/helpers/credential-sessions.builder.ts index a76086122f..757c629a8e 100644 --- a/apps/oid4vc-issuance/libs/helpers/credential-sessions.builder.ts +++ b/apps/oid4vc-issuance/libs/helpers/credential-sessions.builder.ts @@ -33,7 +33,7 @@ export enum SignerMethodOption { export interface DisclosureFrame { _sd?: string[]; - [claim: string]: DisclosureFrame | string[] | undefined; + [claim: string]: DisclosureFrame | DisclosureFrame[] | string[] | undefined; } export interface validityInfo { @@ -230,36 +230,46 @@ export function validatePayloadAgainstTemplate(template: any, payload: any): { v return { valid: 0 === errors.length, errors }; } -function buildDisclosureFrameFromTemplate(attributes: CredentialAttribute[]): DisclosureFrame { +function buildDisclosureFrameFromTemplate( + attributes: CredentialAttribute[], + payload?: Record +): DisclosureFrame { const frame: DisclosureFrame = {}; const sd: string[] = []; for (const attr of attributes) { - const childFrame = - attr.children && 0 < attr.children.length ? buildDisclosureFrameFromTemplate(attr.children) : undefined; - - const hasChildDisclosure = - childFrame && (childFrame._sd?.length || Object.keys(childFrame).some((k) => '_sd' !== k)); - - // Case 1: this attribute itself is disclosed - if (attr.disclose) { - // If it has children, children are handled separately - if (!attr.children || 0 === attr.children.length) { - sd.push(attr.key); - continue; + const hasChildren = attr.children && 0 < attr.children.length; + + if (hasChildren) { + const payloadValue = payload?.[attr.key]; + //todo: + //1) Need to handle the type validation here to ensure payloadValue is in expected format (object or array of objects) + //2) Need to add add validation based on template definition (e.g. if template defines an array, payload must be an array, etc.) + if (Array.isArray(payloadValue)) { + // Array of objects → [{ _sd: [...] }, ...] + const childFrame = buildDisclosureFrameFromTemplate(attr.children!); + frame[attr.key] = payloadValue.map(() => ({ ...childFrame })); + } else { + // Plain object → { _sd: [...] } + const childFrame = buildDisclosureFrameFromTemplate(attr.children!, payloadValue as Record); + const hasChildDisclosure = childFrame._sd?.length || Object.keys(childFrame).some((k) => '_sd' !== k); + if (hasChildDisclosure) { + frame[attr.key] = childFrame; + } } + continue; } - // Case 2: attribute has disclosed children - if (hasChildDisclosure) { - frame[attr.key] = childFrame!; + // Root attribute — add to _sd if disclosed + if (attr.disclose) { + sd.push(attr.key); } } if (0 < sd.length) { frame._sd = sd; } - + // console.log('Built disclosure frame:', JSON.stringify(frame, null, 2)); return frame; } @@ -373,8 +383,11 @@ function buildSdJwtCredential( const apiFormat = mapDbFormatToApiFormat(templateRecord.format); const idSuffix = formatSuffix(apiFormat); const credentialSupportedId = `${templateRecord.name}-${idSuffix}`; - const disclosureFrame = buildDisclosureFrameFromTemplate(sdJwtTemplate.attributes); + const disclosureFrame = buildDisclosureFrameFromTemplate( + sdJwtTemplate.attributes, + payloadCopy as Record + ); return { credentialSupportedId, signerOptions: templateSignerOption ? templateSignerOption : undefined, From 1fa32a1941ff10c90f993e6dc1c99b47907ab379 Mon Sep 17 00:00:00 2001 From: Sagar Khole Date: Mon, 20 Apr 2026 19:05:32 +0530 Subject: [PATCH 15/18] refactor: stabilize SD-JWT status list allocation and index searching Signed-off-by: Sagar Khole --- .../src/agent-service.controller.ts | 4 +- .../src/agent-service.service.ts | 4 +- .../dtos/issuer-sessions.dto.ts | 9 ++-- .../src/oid4vc-issuance.service.ts | 41 +++++++------------ .../src/status-list-allocator.service.ts | 27 ++++++++++-- .../migration.sql | 7 +++- libs/prisma-service/prisma/schema.prisma | 13 ++++-- 7 files changed, 62 insertions(+), 43 deletions(-) diff --git a/apps/agent-service/src/agent-service.controller.ts b/apps/agent-service/src/agent-service.controller.ts index 8faf3f5b7c..5016a08ff4 100644 --- a/apps/agent-service/src/agent-service.controller.ts +++ b/apps/agent-service/src/agent-service.controller.ts @@ -391,8 +391,8 @@ export class AgentServiceController { } @MessagePattern({ cmd: 'agent-service-oid4vc-revoke-credential' }) - async oidcRevokeCredential(payload: { url: string; orgId: string; statusListDetails?: object }): Promise { - return this.agentServiceService.oidcRevokeCredential(payload.url, payload.orgId, payload.statusListDetails); + async oidcRevokeCredential(payload: { url: string; orgId: string }): Promise { + return this.agentServiceService.oidcRevokeCredential(payload.url, payload.orgId); } @MessagePattern({ cmd: 'agent-create-x509-certificate' }) diff --git a/apps/agent-service/src/agent-service.service.ts b/apps/agent-service/src/agent-service.service.ts index f5d47311f2..83db049269 100644 --- a/apps/agent-service/src/agent-service.service.ts +++ b/apps/agent-service/src/agent-service.service.ts @@ -1489,11 +1489,11 @@ export class AgentServiceService { } } - async oidcRevokeCredential(url: string, orgId: string, statusListDetails?: object): Promise { + async oidcRevokeCredential(url: string, orgId: string): Promise { try { const getApiKey = await this.getOrgAgentApiKey(orgId); const data = await this.commonService - .httpPost(`${url}`, statusListDetails || {}, { headers: { authorization: getApiKey } }) + .httpPost(`${url}`, {}, { headers: { authorization: getApiKey } }) .then(async (response) => response); return data; } catch (error) { diff --git a/apps/api-gateway/src/oid4vc-issuance/dtos/issuer-sessions.dto.ts b/apps/api-gateway/src/oid4vc-issuance/dtos/issuer-sessions.dto.ts index bc1d69b624..3a54dbba2b 100644 --- a/apps/api-gateway/src/oid4vc-issuance/dtos/issuer-sessions.dto.ts +++ b/apps/api-gateway/src/oid4vc-issuance/dtos/issuer-sessions.dto.ts @@ -18,7 +18,8 @@ import { ValidatorConstraintInterface, ValidationArguments, Validate, - IsDate + IsDate, + IsBoolean } from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; @@ -190,7 +191,8 @@ export class CreateOidcCredentialOfferDto { @ApiPropertyOptional({ example: true, description: 'Flag to enable revocation for the issued credentials' }) @IsOptional() - isRevocable?: boolean; + @IsBoolean() + isRevocable?: boolean = false; } export class GetAllCredentialOfferDto { @@ -388,7 +390,8 @@ export class CreateCredentialOfferD2ADto { @ApiPropertyOptional({ example: true, description: 'Flag to enable revocation for the issued credentials' }) @IsOptional() - isRevocable?: boolean; + @IsBoolean() + isRevocable?: boolean = false; @ExactlyOneOf(['preAuthorizedCodeFlowConfig', 'authorizationCodeFlowConfig'], { message: 'Provide exactly one of preAuthorizedCodeFlowConfig or authorizationCodeFlowConfig.' diff --git a/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts b/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts index 4fd1aa9da0..19e8ec1fd6 100644 --- a/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts +++ b/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts @@ -667,6 +667,7 @@ export class Oid4vcIssuanceService { if (cred.statusListDetails) { const statusListUri = `${process.env.STATUS_LIST_HOST}/status-lists/${cred.statusListDetails.listId}`; await this.statusListAllocatorService.saveCredentialAllocation( + orgId, `${issuanceSessionId}-${cred.statusListDetails.index}`, cred.statusListDetails.listId, cred.statusListDetails.index, @@ -753,16 +754,12 @@ export class Oid4vcIssuanceService { for (const cred of oidcCredentialD2APayload.credentials) { // eslint-disable-next-line @typescript-eslint/no-explicit-any if ((cred as any).format === CredentialFormat.SdJwtVc && !cred.statusListDetails) { - try { - const allocation = await this.statusListAllocatorService.allocate(orgId, agentDetailsForAlloc.orgDid); - cred.statusListDetails = { - listId: allocation.listId, - index: allocation.index, - listSize: Number(CommonConstants.DEFAULT_STATUS_LIST_SIZE) - }; - } catch (allocError) { - this.logger.warn(`Could not allocate status list index: ${allocError.message}`); - } + const allocation = await this.statusListAllocatorService.allocate(orgId, agentDetailsForAlloc.orgDid); + cred.statusListDetails = { + listId: allocation.listId, + index: allocation.index, + listSize: Number(CommonConstants.DEFAULT_STATUS_LIST_SIZE) + }; } } } @@ -773,7 +770,6 @@ export class Oid4vcIssuanceService { throw new NotFoundException(ResponseMessages.oidcIssuerSession.error.errorCreateOffer); } - // Save allocations now that we have the session id let parsedResponse; if ('string' === typeof createCredentialOfferOnAgent.response) { parsedResponse = JSON.parse(createCredentialOfferOnAgent.response); @@ -791,6 +787,7 @@ export class Oid4vcIssuanceService { if (cred.statusListDetails) { const statusListUri = `${process.env.STATUS_LIST_HOST}/status-lists/${cred.statusListDetails.listId}`; await this.statusListAllocatorService.saveCredentialAllocation( + orgId, `${issuanceSessionId}-${cred.statusListDetails.index}`, cred.statusListDetails.listId, cred.statusListDetails.index, @@ -909,7 +906,7 @@ export class Oid4vcIssuanceService { throw new BadRequestException('Please provide a valid issuanceSessionId'); } - const allocations = await this.statusListAllocatorService.getCredentialAllocations(issuanceSessionId); + const allocations = await this.statusListAllocatorService.getCredentialAllocations(orgId, issuanceSessionId); const url = getAgentUrl( await this.getAgentEndpoint(orgId), @@ -919,25 +916,15 @@ export class Oid4vcIssuanceService { const pattern = { cmd: 'agent-service-oid4vc-revoke-credential' }; - let lastResponse; if (allocations && 0 < allocations.length) { - for (const allocation of allocations) { - const payload: any = { - url, - orgId, - statusListDetails: { - listId: allocation.listId, - index: allocation.index, - listSize: Number(CommonConstants.DEFAULT_STATUS_LIST_SIZE) - } - }; - lastResponse = await this.natsCall(pattern, payload); - } + const payload: any = { + url, + orgId + }; + return this.natsCall(pattern, payload); } else { throw new BadRequestException('Credential is not revocable as no status list allocation was found.'); } - - return lastResponse; } catch (error) { this.logger.error(`[revokeCredential] - error: ${JSON.stringify(error)}`); throw new RpcException(error.response ?? error); diff --git a/apps/oid4vc-issuance/src/status-list-allocator.service.ts b/apps/oid4vc-issuance/src/status-list-allocator.service.ts index c44c81452c..28d1e8f6d6 100644 --- a/apps/oid4vc-issuance/src/status-list-allocator.service.ts +++ b/apps/oid4vc-issuance/src/status-list-allocator.service.ts @@ -46,15 +46,32 @@ export class RandomBitmapIndexAllocator { throw new Error('No indexes left'); } - while (true) { + const maxRandomAttempts = 32; + for (let i = 0; i < maxRandomAttempts; i++) { const idx = randomInt(this.capacity); - if (!this.isSet(idx)) { this.set(idx); this.allocatedCount++; return idx; } } + + const startByte = randomInt(this.bitmap.length); + for (let i = 0; i < this.bitmap.length; i++) { + const byteIdx = (startByte + i) % this.bitmap.length; + if (255 !== this.bitmap[byteIdx]) { + for (let bit = 0; 8 > bit; bit++) { + const idx = (byteIdx << 3) + bit; + if (idx < this.capacity && !this.isSet(idx)) { + this.set(idx); + this.allocatedCount++; + return idx; + } + } + } + } + + throw new Error('No indexes left'); } public isIndexAllocated(index: number): boolean { @@ -149,6 +166,7 @@ export class StatusListAllocatorService { } async saveCredentialAllocation( + orgId: string, credentialId: string, listId: string, index: number, @@ -157,6 +175,7 @@ export class StatusListAllocatorService { ): Promise { await this.prisma.issued_oid4vc_credentials.create({ data: { + orgId, credentialId, listId, index, @@ -166,9 +185,9 @@ export class StatusListAllocatorService { }); } - async getCredentialAllocations(issuanceSessionId: string): Promise { + async getCredentialAllocations(orgId: string, issuanceSessionId: string): Promise { return this.prisma.issued_oid4vc_credentials.findMany({ - where: { issuanceSessionId } + where: { orgId, issuanceSessionId } }); } diff --git a/libs/prisma-service/prisma/migrations/20260323183000_index_allocation/migration.sql b/libs/prisma-service/prisma/migrations/20260323183000_index_allocation/migration.sql index c4e22f74bd..f57bb7c698 100644 --- a/libs/prisma-service/prisma/migrations/20260323183000_index_allocation/migration.sql +++ b/libs/prisma-service/prisma/migrations/20260323183000_index_allocation/migration.sql @@ -20,7 +20,8 @@ CREATE TABLE "issued_oid4vc_credentials" ( "credentialId" TEXT NOT NULL, "listId" UUID NOT NULL, "index" INTEGER NOT NULL, - "issuanceSessionId" VARCHAR NOT NULL, + "issuanceSessionId" VARCHAR(100) NOT NULL, + "orgId" UUID, "createDateTime" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL, "statusListUri" TEXT NOT NULL, @@ -36,9 +37,13 @@ CREATE INDEX "status_list_allocation_orgId_isActive_idx" ON "status_list_allocat -- CreateIndex CREATE UNIQUE INDEX "issued_oid4vc_credentials_credentialId_key" ON "issued_oid4vc_credentials"("credentialId"); +CREATE INDEX "issued_oid4vc_credentials_orgId_issuanceSessionId_idx" ON "issued_oid4vc_credentials"("orgId", "issuanceSessionId"); -- AddForeignKey ALTER TABLE "status_list_allocation" ADD CONSTRAINT "status_list_allocation_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "organisation"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +-- AddForeignKey +ALTER TABLE "issued_oid4vc_credentials" ADD CONSTRAINT "issued_oid4vc_credentials_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "organisation"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + -- Add webhookSecret safely ALTER TABLE "org_agents" ADD COLUMN IF NOT EXISTS "webhookSecret" VARCHAR; diff --git a/libs/prisma-service/prisma/schema.prisma b/libs/prisma-service/prisma/schema.prisma index c2db4a4436..bec245be82 100755 --- a/libs/prisma-service/prisma/schema.prisma +++ b/libs/prisma-service/prisma/schema.prisma @@ -161,6 +161,7 @@ model organisation { ecosystemOrgs ecosystem_orgs[] ecosystem_invitations ecosystem_invitations[] status_list_allocations status_list_allocation[] + issued_oid4vc_credentials issued_oid4vc_credentials[] } model org_invitations { @@ -240,7 +241,6 @@ model org_agents { org_agent_type org_agents_type? @relation(fields: [orgAgentTypeId], references: [id]) organisation organisation? @relation(fields: [orgId], references: [id]) webhookUrl String? @db.VarChar - webhookSecret String? @db.VarChar org_dids org_dids[] oidc_issuer oidc_issuer[] x509_certificates x509_certificates[] @@ -601,7 +601,7 @@ model oidc_issuer { model oid4vc_credentials { id String @id @default(uuid()) @db.Uuid - orgId String @db.Uuid + orgId String? @db.Uuid issuanceSessionId String @unique credentialOfferId String state String @@ -614,7 +614,7 @@ model oid4vc_credentials { issuedCredentials String[] publicIssuerId String - organisation organisation @relation(fields: [orgId], references: [id]) + organisation organisation? @relation(fields: [orgId], references: [id]) @@index([credentialConfigurationIds], type: Gin) } @@ -655,10 +655,15 @@ model issued_oid4vc_credentials { credentialId String @unique listId String @db.Uuid index Int - issuanceSessionId String @db.VarChar + issuanceSessionId String @db.VarChar(100) + orgId String? @db.Uuid createDateTime DateTime @default(now()) @db.Timestamptz(6) updatedAt DateTime @updatedAt statusListUri String + + organisation organisation? @relation(fields: [orgId], references: [id]) + + @@index([orgId, issuanceSessionId]) } enum SignerOption { From ac3c2f5667257790868ca1a439941a34addd0c2e Mon Sep 17 00:00:00 2001 From: Sagar Khole Date: Tue, 21 Apr 2026 10:31:19 +0530 Subject: [PATCH 16/18] refactor(issuance):handle missing STATUS_LIST_HOST configuration Signed-off-by: Sagar Khole --- apps/oid4vc-issuance/src/main.ts | 3 +- .../src/oid4vc-issuance.service.ts | 54 ++++++------------- 2 files changed, 18 insertions(+), 39 deletions(-) diff --git a/apps/oid4vc-issuance/src/main.ts b/apps/oid4vc-issuance/src/main.ts index 9db3630bbf..68aa19bbfd 100644 --- a/apps/oid4vc-issuance/src/main.ts +++ b/apps/oid4vc-issuance/src/main.ts @@ -11,8 +11,7 @@ const logger = new Logger(); async function bootstrap(): Promise { if (!process.env.STATUS_LIST_HOST) { - logger.error('STATUS_LIST_HOST is not configured. Microservice cannot start.'); - process.exit(1); + logger.warn('STATUS_LIST_HOST is not configured. Revocable SD-JWT flow will be disabled.'); } const app = await NestFactory.createMicroservice(Oid4vcIssuanceModule, { diff --git a/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts b/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts index 0774e53408..d73bfe502e 100644 --- a/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts +++ b/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts @@ -632,6 +632,9 @@ export class Oid4vcIssuanceService { if (createOidcCredentialOffer.isRevocable) { const hasSdJwt = buildOidcCredentialOffer.credentials.some((c) => c.format === CredentialFormat.SdJwtVc); if (hasSdJwt) { + if (!process.env.STATUS_LIST_HOST) { + throw new BadRequestException('Revocable SD-JWT is not supported as STATUS_LIST_HOST is not configured.'); + } const agentDetailsForAlloc = await this.oid4vcIssuanceRepository.getAgentEndPoint(orgId); if (!agentDetailsForAlloc?.orgDid) { throw new BadRequestException('Organization DID is required for revocable SD-JWT credentials'); @@ -671,55 +674,29 @@ export class Oid4vcIssuanceService { } // Revocation logic to save the credential offer id and status list details in DB for later use during revocation - let parsedResponse; + let responseToSave; if ('string' === typeof createCredentialOfferOnAgent.response) { - parsedResponse = JSON.parse(createCredentialOfferOnAgent.response); + responseToSave = JSON.parse(createCredentialOfferOnAgent.response); } else { - parsedResponse = createCredentialOfferOnAgent.response; + responseToSave = createCredentialOfferOnAgent.response; } - const issuanceSessionId = - parsedResponse.issuanceSessionId || - parsedResponse.credentialOfferId || - parsedResponse.id || - parsedResponse.issuanceSession?.id; - if (issuanceSessionId && createOidcCredentialOffer.isRevocable) { - for (const cred of buildOidcCredentialOffer.credentials) { - if (cred.statusListDetails) { - const statusListUri = `${process.env.STATUS_LIST_HOST}/status-lists/${cred.statusListDetails.listId}`; - await this.statusListAllocatorService.saveCredentialAllocation( - `${issuanceSessionId}-${cred.statusListDetails.index}`, - cred.statusListDetails.listId, - cred.statusListDetails.index, - issuanceSessionId, - statusListUri - ); - } - } - } - - let parsedResponse; - if ('string' === typeof createCredentialOfferOnAgent.response) { - parsedResponse = JSON.parse(createCredentialOfferOnAgent.response); - } else { - parsedResponse = createCredentialOfferOnAgent.response; - } + const sessionId = + responseToSave.issuanceSessionId || + responseToSave.credentialOfferId || + responseToSave.id || + responseToSave.issuanceSession?.id; - const issuanceSessionId = - parsedResponse.issuanceSessionId || - parsedResponse.credentialOfferId || - parsedResponse.id || - parsedResponse.issuanceSession?.id; - if (issuanceSessionId && createOidcCredentialOffer.isRevocable) { + if (sessionId && createOidcCredentialOffer.isRevocable) { for (const cred of buildOidcCredentialOffer.credentials) { if (cred.statusListDetails) { const statusListUri = `${process.env.STATUS_LIST_HOST}/status-lists/${cred.statusListDetails.listId}`; await this.statusListAllocatorService.saveCredentialAllocation( orgId, - `${issuanceSessionId}-${cred.statusListDetails.index}`, + `${sessionId}-${cred.statusListDetails.index}`, cred.statusListDetails.listId, cred.statusListDetails.index, - issuanceSessionId, + sessionId, statusListUri ); } @@ -795,6 +772,9 @@ export class Oid4vcIssuanceService { if (oidcCredentialD2APayload.isRevocable) { const hasSdJwt = oidcCredentialD2APayload.credentials.some((c) => c.format === CredentialFormat.SdJwtVc); if (hasSdJwt) { + if (!process.env.STATUS_LIST_HOST) { + throw new BadRequestException('Revocable SD-JWT is not supported as STATUS_LIST_HOST is not configured.'); + } const agentDetailsForAlloc = await this.oid4vcIssuanceRepository.getAgentEndPoint(orgId); if (!agentDetailsForAlloc?.orgDid) { throw new BadRequestException('Organization DID is required for revocable credentials'); From e978e29d3e9cf437a79d0b2b30863047ff7d9aa6 Mon Sep 17 00:00:00 2001 From: Sagar Khole Date: Tue, 21 Apr 2026 12:23:41 +0530 Subject: [PATCH 17/18] fix: stabilize sd-jwt revocation rollback and synchronize status list counters Signed-off-by: Sagar Khole --- .env.demo | 1 - .env.sample | 1 - .../src/agent-service.service.ts | 3 +- .../src/oid4vc-issuance.service.ts | 42 +++++++------------ .../src/status-list-allocator.service.ts | 8 +++- 5 files changed, 24 insertions(+), 31 deletions(-) diff --git a/.env.demo b/.env.demo index e649121553..3b97b35099 100644 --- a/.env.demo +++ b/.env.demo @@ -254,4 +254,3 @@ NATS_AUTH_TYPE=nkey NOTIFICATION_NATS_AUTH_TYPE= ENABLE_NATS_NOTIFICATION=false -STATUS_LIST_HOST= diff --git a/.env.sample b/.env.sample index d5cc1ef59f..32474e7643 100644 --- a/.env.sample +++ b/.env.sample @@ -282,4 +282,3 @@ NATS_AUTH_TYPE=nkey NOTIFICATION_NATS_AUTH_TYPE= ENABLE_NATS_NOTIFICATION=false -STATUS_LIST_HOST= diff --git a/apps/agent-service/src/agent-service.service.ts b/apps/agent-service/src/agent-service.service.ts index 83db049269..0c09d315ce 100644 --- a/apps/agent-service/src/agent-service.service.ts +++ b/apps/agent-service/src/agent-service.service.ts @@ -1497,7 +1497,8 @@ export class AgentServiceService { .then(async (response) => response); return data; } catch (error) { - this.logger.error(`Error in _oidcRevokeCredential in agent service : ${JSON.stringify(error)}`); + const errorMsg = error.response ? JSON.stringify(error.response.data) : error.message; + this.logger.error(`Error in oidcRevokeCredential in agent service : ${errorMsg}`); throw error; } } diff --git a/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts b/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts index d73bfe502e..811da6aa19 100644 --- a/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts +++ b/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts @@ -533,6 +533,7 @@ export class Oid4vcIssuanceService { issuerId: string ): Promise { let buildOidcCredentialOffer: CredentialOfferPayload; + const newlyAllocatedIndices: { listId: string; index: number }[] = []; try { const filterTemplateIds = extractTemplateIds(createOidcCredentialOffer); if (!filterTemplateIds) { @@ -640,13 +641,14 @@ export class Oid4vcIssuanceService { throw new BadRequestException('Organization DID is required for revocable SD-JWT credentials'); } for (const cred of buildOidcCredentialOffer.credentials) { - if (cred.format === CredentialFormat.SdJwtVc) { + if (cred.format === CredentialFormat.SdJwtVc && !cred.statusListDetails) { const allocation = await this.statusListAllocatorService.allocate(orgId, agentDetailsForAlloc.orgDid); cred.statusListDetails = { listId: allocation.listId, index: allocation.index, listSize: Number(CommonConstants.DEFAULT_STATUS_LIST_SIZE) }; + newlyAllocatedIndices.push({ listId: allocation.listId, index: allocation.index }); } } } @@ -705,18 +707,11 @@ export class Oid4vcIssuanceService { return createCredentialOfferOnAgent.response; } catch (error) { - if (buildOidcCredentialOffer?.credentials) { - for (const cred of buildOidcCredentialOffer.credentials) { - if (cred.statusListDetails) { - try { - await this.statusListAllocatorService.release( - cred.statusListDetails.listId, - cred.statusListDetails.index - ); - } catch (releaseErr) { - this.logger.warn(`Failed to release index on rollback: ${releaseErr.message}`); - } - } + for (const alloc of newlyAllocatedIndices) { + try { + await this.statusListAllocatorService.release(alloc.listId, alloc.index); + } catch (releaseErr) { + this.logger.warn(`Failed to release index on rollback: ${releaseErr.message}`); } } @@ -730,6 +725,7 @@ export class Oid4vcIssuanceService { } async createOidcCredentialOfferD2A(oidcCredentialD2APayload, orgId: string): Promise { + const newlyAllocatedIndices: { listId: string; index: number }[] = []; try { for (const credential of oidcCredentialD2APayload.credentials) { const { signerOptions } = credential; @@ -788,6 +784,7 @@ export class Oid4vcIssuanceService { index: allocation.index, listSize: Number(CommonConstants.DEFAULT_STATUS_LIST_SIZE) }; + newlyAllocatedIndices.push({ listId: allocation.listId, index: allocation.index }); } } } @@ -828,21 +825,14 @@ export class Oid4vcIssuanceService { return createCredentialOfferOnAgent.response; } catch (error) { - if (oidcCredentialD2APayload?.credentials) { - for (const cred of oidcCredentialD2APayload.credentials) { - if (cred.statusListDetails) { - try { - await this.statusListAllocatorService.release( - cred.statusListDetails.listId, - cred.statusListDetails.index - ); - } catch (releaseErr) { - this.logger.warn(`Failed to release index on rollback: ${releaseErr.message}`); - } - } + for (const alloc of newlyAllocatedIndices) { + try { + await this.statusListAllocatorService.release(alloc.listId, alloc.index); + } catch (releaseErr) { + this.logger.warn(`Failed to release index on rollback: ${releaseErr.message}`); } } - this.logger.error(`[createOidcCredentialOffer] - error: ${JSON.stringify(error)}`); + this.logger.error(`[createOidcCredentialOfferD2A] - error: ${JSON.stringify(error)}`); throw new RpcException(error.response ?? error); } } diff --git a/apps/oid4vc-issuance/src/status-list-allocator.service.ts b/apps/oid4vc-issuance/src/status-list-allocator.service.ts index 28d1e8f6d6..028f9c38c6 100644 --- a/apps/oid4vc-issuance/src/status-list-allocator.service.ts +++ b/apps/oid4vc-issuance/src/status-list-allocator.service.ts @@ -95,6 +95,10 @@ export class RandomBitmapIndexAllocator { public export(): Uint8Array { return new Uint8Array(this.bitmap); } + + public getAllocatedCount(): number { + return this.allocatedCount; + } } @Injectable() @@ -146,7 +150,7 @@ export class StatusListAllocatorService { where: { id: activeList.id }, data: { bitmap: Buffer.from(allocator.export()), - allocatedCount: activeList.allocatedCount + 1 + allocatedCount: allocator.getAllocatedCount() } }); @@ -208,7 +212,7 @@ export class StatusListAllocatorService { where: { listId }, data: { bitmap: Buffer.from(allocator.export()), - allocatedCount: Math.max(0, allocation.allocatedCount - 1) + allocatedCount: allocator.getAllocatedCount() } }); }); From 661b9f0b2fce671b0f3494f024747d9a626ad468 Mon Sep 17 00:00:00 2001 From: Sagar Khole Date: Wed, 22 Apr 2026 12:34:43 +0530 Subject: [PATCH 18/18] refactor:removed duplicate veribles from .env.demo Signed-off-by: Sagar Khole --- .env.demo | 1 - 1 file changed, 1 deletion(-) diff --git a/.env.demo b/.env.demo index 8499386460..d34cbd651a 100644 --- a/.env.demo +++ b/.env.demo @@ -344,7 +344,6 @@ STATUS_LIST_HOST= NATS_CREDS_FILE= -ENABLE_NATS_NOTIFICATION=false # 'nkey' | 'creds' | 'usernamePassword' | 'none' # Setting this to none, as we don't use auth with default local setup. NATS_AUTH_TYPE=none