From 3b01f9698b0229f22283df2934c1aca64599acae Mon Sep 17 00:00:00 2001 From: Tipu_Singh Date: Fri, 12 Sep 2025 18:46:25 +0530 Subject: [PATCH 1/8] feat: oidc implementation Signed-off-by: Tipu_Singh --- .../src/agent-service.controller.ts | 34 + .../src/agent-service.service.ts | 78 +++ .../src/issuance/dtos/issuer-sessions.dto.ts | 224 +++++++ .../issuance/dtos/oidc-issuer-template.dto.ts | 182 +++++ .../src/issuance/dtos/oidc-issuer.dto.ts | 289 ++++++++ .../src/issuance/issuance.module.ts | 3 +- .../src/issuance/issuance.service.ts | 94 ++- .../src/issuance/oidc.controller.ts | 490 ++++++++++++++ apps/issuance/constant/issuance.ts | 6 + .../interfaces/oidc-issuance.interfaces.ts | 147 +++++ .../oidc-issuer-sessions.interfaces.ts | 51 ++ .../interfaces/oidc-template.interface.ts | 26 + .../helpers/credential-sessions.builder.ts | 271 ++++++++ apps/issuance/libs/helpers/issuer.metadata.ts | 244 +++++++ apps/issuance/src/issuance.controller.ts | 130 +++- apps/issuance/src/issuance.module.ts | 2 + apps/issuance/src/issuance.repository.ts | 185 ++++++ apps/issuance/src/oidc-issuance.service.ts | 622 ++++++++++++++++++ libs/common/src/common.constant.ts | 17 +- libs/common/src/common.utils.ts | 11 +- libs/common/src/response-messages/index.ts | 48 ++ .../migration.sql | 94 +++ .../migration.sql | 17 + .../migration.sql | 32 + .../migration.sql | 21 + .../migration.sql | 6 + .../migration.sql | 18 + libs/prisma-service/prisma/schema.prisma | 37 ++ 28 files changed, 3373 insertions(+), 6 deletions(-) create mode 100644 apps/api-gateway/src/issuance/dtos/issuer-sessions.dto.ts create mode 100644 apps/api-gateway/src/issuance/dtos/oidc-issuer-template.dto.ts create mode 100644 apps/api-gateway/src/issuance/dtos/oidc-issuer.dto.ts create mode 100644 apps/api-gateway/src/issuance/oidc.controller.ts create mode 100644 apps/issuance/constant/issuance.ts create mode 100644 apps/issuance/interfaces/oidc-issuance.interfaces.ts create mode 100644 apps/issuance/interfaces/oidc-issuer-sessions.interfaces.ts create mode 100644 apps/issuance/interfaces/oidc-template.interface.ts create mode 100644 apps/issuance/libs/helpers/credential-sessions.builder.ts create mode 100644 apps/issuance/libs/helpers/issuer.metadata.ts create mode 100644 apps/issuance/src/oidc-issuance.service.ts create mode 100644 libs/prisma-service/prisma/migrations/20250814141522_add_supported_protocol/migration.sql create mode 100644 libs/prisma-service/prisma/migrations/20250822104325_oidc_issuer_templates/migration.sql create mode 100644 libs/prisma-service/prisma/migrations/20250822145801_fix_issuer_relation/migration.sql create mode 100644 libs/prisma-service/prisma/migrations/20250902082321_add_org_agent_id_to_oidc_issuer/migration.sql create mode 100644 libs/prisma-service/prisma/migrations/20250904181430_added_fields_in_oidc_issuer/migration.sql create mode 100644 libs/prisma-service/prisma/migrations/20250911102632_added_field_signer_option_in_credential_templates/migration.sql diff --git a/apps/agent-service/src/agent-service.controller.ts b/apps/agent-service/src/agent-service.controller.ts index d368781cd..dcd3cf149 100644 --- a/apps/agent-service/src/agent-service.controller.ts +++ b/apps/agent-service/src/agent-service.controller.ts @@ -323,4 +323,38 @@ export class AgentServiceController { async agentdetailsByOrgId(payload: { orgId: string }): Promise { return this.agentServiceService.getAgentDetails(payload.orgId); } + + @MessagePattern({ cmd: 'agent-create-oidc-issuer' }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async oidcIssuerCreate(payload: { issuerCreation; url: string; orgId: string }): Promise { + return this.agentServiceService.oidcIssuerCreate(payload.issuerCreation, payload.url, payload.orgId); + } + @MessagePattern({ cmd: 'delete-oidc-issuer' }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async oidcDeleteIssuer(payload: { url: string; orgId: string }): Promise { + return this.agentServiceService.deleteOidcIssuer(payload.url, payload.orgId); + } + @MessagePattern({ cmd: 'agent-create-oidc-template' }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async oidcIssuerTemplate(payload: { templatePayload; url: string; orgId: string }): Promise { + return this.agentServiceService.oidcIssuerTemplate(payload.templatePayload, payload.url, payload.orgId); + } + //TODO: change message for oidc + @MessagePattern({ cmd: 'oidc-get-issuer-by-id' }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async oidcGetIssuerById(payload: { url: string; orgId: string }): Promise { + return this.agentServiceService.oidcGetIssuerById(payload.url, payload.orgId); + } + + @MessagePattern({ cmd: 'oidc-get-issuers' }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async oidcGetIssuers(payload: { url: string; orgId: string }): Promise { + return this.agentServiceService.oidcGetIssuers(payload.url, payload.orgId); + } + + @MessagePattern({ cmd: 'agent-service-oidc-create-credential-offer' }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async oidcCreateCredentialOffer(payload: { credentialPayload; url: string; orgId: string }): Promise { + return this.agentServiceService.oidcCreateCredentialOffer(payload.credentialPayload, payload.url, payload.orgId); + } } diff --git a/apps/agent-service/src/agent-service.service.ts b/apps/agent-service/src/agent-service.service.ts index ffa860199..7a4709b2b 100644 --- a/apps/agent-service/src/agent-service.service.ts +++ b/apps/agent-service/src/agent-service.service.ts @@ -1410,6 +1410,84 @@ export class AgentServiceService { } } + async oidcIssuerCreate(issueData, url: string, orgId: string): Promise { + try { + const getApiKey = await this.getOrgAgentApiKey(orgId); + const data = await this.commonService + .httpPost(url, issueData, { headers: { authorization: getApiKey } }) + .then(async (response) => response); + return data; + } catch (error) { + this.logger.error(`Error in oidcIssuerCreate in agent service : ${JSON.stringify(error)}`); + throw error; + } + } + + async deleteOidcIssuer(url: string, orgId: string): Promise { + try { + const getApiKey = await this.getOrgAgentApiKey(orgId); + const data = await this.commonService + .httpDelete(url, { headers: { authorization: getApiKey } }) + .then(async (response) => response); + return data; + } catch (error) { + this.logger.error(`Error in deleteOidcIssuer in agent service : ${JSON.stringify(error)}`); + throw error; + } + } + + async oidcGetIssuerById(url: string, orgId: string): Promise { + try { + const getApiKey = await this.getOrgAgentApiKey(orgId); + const data = await this.commonService + .httpGet(url, { headers: { authorization: getApiKey } }) + .then(async (response) => response); + return data; + } catch (error) { + this.logger.error(`Error in oidcGetIssuerById in agent service : ${JSON.stringify(error)}`); + throw error; + } + } + + async oidcGetIssuers(url: string, orgId: string): Promise { + try { + const getApiKey = await this.getOrgAgentApiKey(orgId); + const data = await this.commonService + .httpGet(url, { headers: { authorization: getApiKey } }) + .then(async (response) => response); + return data; + } catch (error) { + this.logger.error(`Error in oidcGetIssuers in agent service : ${JSON.stringify(error)}`); + throw error; + } + } + + async oidcCreateCredentialOffer(credentialPayload, url: string, orgId: string): Promise { + try { + const getApiKey = await this.getOrgAgentApiKey(orgId); + const data = await this.commonService + .httpPost(url, credentialPayload, { headers: { authorization: getApiKey } }) + .then(async (response) => response); + return data; + } catch (error) { + this.logger.error(`Error in oidcCreateCredentialOffer in agent service : ${JSON.stringify(error)}`); + throw error; + } + } + + async oidcIssuerTemplate(templatePayload, url: string, orgId: string): Promise { + try { + const getApiKey = await this.getOrgAgentApiKey(orgId); + const data = await this.commonService + .httpPut(url, templatePayload, { headers: { authorization: getApiKey } }) + .then(async (response) => response); + return data; + } catch (error) { + this.logger.error(`Error in oidcIssuerTemplate in agent service : ${JSON.stringify(error)}`); + throw error; + } + } + async getIssueCredentials(url: string, apiKey: string): Promise { try { const data = await this.commonService diff --git a/apps/api-gateway/src/issuance/dtos/issuer-sessions.dto.ts b/apps/api-gateway/src/issuance/dtos/issuer-sessions.dto.ts new file mode 100644 index 000000000..a31c65799 --- /dev/null +++ b/apps/api-gateway/src/issuance/dtos/issuer-sessions.dto.ts @@ -0,0 +1,224 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +/* eslint-disable @typescript-eslint/naming-convention, @typescript-eslint/explicit-function-return-type, @typescript-eslint/explicit-module-boundary-types, camelcase */ + +import { + IsArray, + IsEnum, + IsNotEmpty, + IsObject, + IsOptional, + IsString, + Matches, + ValidateNested, + registerDecorator, + ValidationOptions, + IsInt, + Min, + IsIn, + ArrayMinSize, + IsUrl, + ValidatorConstraint, + ValidatorConstraintInterface, + ValidationArguments, + Validate +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; + +export enum CredentialFormat { + SdJwtVc = 'vc+sd-jwt', + Mdoc = 'mdoc' +} + +export enum SignerMethodOption { + DID = 'did', + X5C = 'x5c' +} + +/** ---------- custom validator: disclosureFrame ---------- */ +function isDisclosureFrameValue(v: unknown): boolean { + if ('boolean' === typeof v) { + return true; + } + if (v && 'object' === typeof v && !Array.isArray(v)) { + return Object.values(v as Record).every((x) => 'boolean' === typeof x); + } + return false; +} + +export function IsDisclosureFrame(options?: ValidationOptions) { + return function (object: unknown, propertyName: string) { + registerDecorator({ + name: 'IsDisclosureFrame', + target: (object as object).constructor, + propertyName, + options, + validator: { + validate(value: unknown) { + if (value === undefined) { + return true; + } + if (!value || 'object' !== typeof value || Array.isArray(value)) { + return false; + } + return Object.values(value as Record).every(isDisclosureFrameValue); + }, + defaultMessage() { + return 'disclosureFrame must be a map of booleans or nested maps of booleans'; + } + } + }); + }; +} + +/** ---------- payload DTOs ---------- */ +export class CredentialPayloadDto { + @ApiPropertyOptional() + @IsOptional() + @IsString() + vct?: string; + + @ApiPropertyOptional({ example: 'Garry' }) + @IsOptional() + @IsString() + full_name?: string; + + @ApiPropertyOptional({ example: '2000-01-01', description: 'YYYY-MM-DD' }) + @IsOptional() + @Matches(/^\d{4}-\d{2}-\d{2}$/, { message: 'birth_date must be YYYY-MM-DD' }) + birth_date?: string; + + @ApiPropertyOptional({ example: 'Africa' }) + @IsOptional() + @IsString() + birth_place?: string; + + @ApiPropertyOptional({ example: 'James Bear' }) + @IsOptional() + @IsString() + parent_names?: string; + + [key: string]: unknown; +} + +export class CredentialRequestDto { + @ApiProperty({ example: '1b2d3c4e-...' }) + @IsString() + @IsNotEmpty() + templateId!: string; + + @ApiProperty({ enum: CredentialFormat, example: CredentialFormat.SdJwtVc }) + @IsEnum(CredentialFormat) + format!: CredentialFormat; + + @ApiProperty({ + type: CredentialPayloadDto, + description: 'Credential payload (structure depends on the format)' + }) + @ValidateNested() + @Type(() => CredentialPayloadDto) + payload!: CredentialPayloadDto; + + @ApiPropertyOptional({ + description: 'Selective disclosure frame (claim -> boolean or nested map).', + example: { full_name: true, birth_date: true, birth_place: false, parent_names: false }, + required: false + }) + @IsOptional() + @IsObject() + @IsDisclosureFrame() + disclosureFrame?: Record>; +} + +/** ---------- auth-config DTOs ---------- */ +export class TxCodeDto { + @ApiPropertyOptional({ example: 'test abc' }) + @IsOptional() + @IsString() + description?: string; + + @ApiProperty({ example: 4 }) + @IsInt() + @Min(1) + length!: number; + + @ApiProperty({ example: 'numeric', enum: ['numeric'] }) + @IsString() + @IsIn(['numeric']) + input_mode!: 'numeric'; +} + +export class PreAuthorizedCodeFlowConfigDto { + @ApiProperty({ type: TxCodeDto }) + @ValidateNested() + @Type(() => TxCodeDto) + txCode!: TxCodeDto; + + @ApiProperty({ + example: 'http://localhost:4001/oid4vci/abc-gov', + description: 'AS (Authorization Server) base URL' + }) + @IsUrl({ require_tld: false }) + authorizationServerUrl!: string; +} + +export class AuthorizationCodeFlowConfigDto { + @ApiProperty({ + example: 'https://id.credebl.ae:8443/realms/credebl', + description: 'AS (Authorization Server) base URL' + }) + @IsUrl({ require_tld: false }) + authorizationServerUrl!: string; +} + +/** ---------- class-level constraint: EXACTLY ONE of the two configs ---------- */ +@ValidatorConstraint({ name: 'ExactlyOneOf', async: false }) +class ExactlyOneOfConstraint implements ValidatorConstraintInterface { + validate(_: unknown, args: ValidationArguments) { + const obj = args.object as Record; + const keys = (args.constraints ?? []) as string[]; + const present = keys.filter((k) => obj[k] !== undefined && null !== obj[k]); + return 1 === present.length; + } + defaultMessage(args: ValidationArguments) { + const keys = (args.constraints ?? []) as string[]; + return `Exactly one of [${keys.join(', ')}] must be provided (not both, not neither).`; + } +} +function ExactlyOneOf(keys: string[], options?: ValidationOptions) { + return Validate(ExactlyOneOfConstraint, keys, options); +} + +/** ---------- root DTO (no authenticationType) ---------- */ +export class CreateOidcCredentialOfferDto { + @ApiProperty({ + type: [CredentialRequestDto], + description: 'At least one credential to be issued.' + }) + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => CredentialRequestDto) + credentials!: CredentialRequestDto[]; + + // Each is optional individually; XOR rule below enforces exactly one present. + @ApiPropertyOptional({ type: PreAuthorizedCodeFlowConfigDto }) + @IsOptional() + @ValidateNested() + @Type(() => PreAuthorizedCodeFlowConfigDto) + preAuthorizedCodeFlowConfig?: PreAuthorizedCodeFlowConfigDto; + + @ApiPropertyOptional({ type: AuthorizationCodeFlowConfigDto }) + @IsOptional() + @ValidateNested() + @Type(() => AuthorizationCodeFlowConfigDto) + authorizationCodeFlowConfig?: AuthorizationCodeFlowConfigDto; + + issuerId?: string; + + // Host the class-level XOR validator on a dummy property + @ExactlyOneOf(['preAuthorizedCodeFlowConfig', 'authorizationCodeFlowConfig'], { + message: 'Provide exactly one of preAuthorizedCodeFlowConfig or authorizationCodeFlowConfig.' + }) + private readonly _exactlyOne?: unknown; +} diff --git a/apps/api-gateway/src/issuance/dtos/oidc-issuer-template.dto.ts b/apps/api-gateway/src/issuance/dtos/oidc-issuer-template.dto.ts new file mode 100644 index 000000000..efd901a89 --- /dev/null +++ b/apps/api-gateway/src/issuance/dtos/oidc-issuer-template.dto.ts @@ -0,0 +1,182 @@ +/* eslint-disable camelcase */ +import { + IsString, + IsBoolean, + IsOptional, + IsEnum, + ValidateNested, + IsObject, + IsNotEmpty, + IsArray +} from 'class-validator'; +import { ApiExtraModels, ApiProperty, ApiPropertyOptional, getSchemaPath, PartialType } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { DisplayDto } from './oidc-issuer.dto'; + +export class CredentialAttributeDto { + @ApiProperty({ required: false, description: 'Whether the attribute is mandatory' }) + @IsOptional() + @IsBoolean() + mandatory?: boolean; + + @ApiProperty({ description: 'Type of the attribute value (string, number, date, etc.)' }) + @IsString() + value_type: string; + + @ApiProperty({ type: [DisplayDto], required: false, description: 'Localized display values' }) + @IsOptional() + @ValidateNested({ each: true }) + @Type(() => DisplayDto) + display?: DisplayDto[]; +} + +class LogoDto { + @ApiPropertyOptional({ + example: 'https://upload.wikimedia.org/wikipedia/commons/2/2f/ABC-2021-LOGO.svg' + }) + @IsString() + @IsNotEmpty() + uri: string; + + @ApiPropertyOptional({ example: 'abc_logo' }) + @IsString() + @IsOptional() + alt_text?: string; +} + +class CredentialDisplayDto { + @ApiPropertyOptional({ example: 'Birth Certificate' }) + @IsString() + @IsNotEmpty() + name: string; + + @ApiPropertyOptional({ example: 'Official record of birth' }) + @IsString() + @IsOptional() + description?: string; + + @ApiPropertyOptional({ example: 'en' }) + @IsString() + @IsOptional() + locale?: string; + + @ApiPropertyOptional({ + example: { + uri: 'https://upload.wikimedia.org/wikipedia/commons/2/2f/ABC-2021-LOGO.svg', + alt_text: 'abc_logo' + } + }) + @IsOptional() + @IsObject() + @ValidateNested() + @Type(() => LogoDto) + logo?: LogoDto; +} + +export class AppearanceDto { + @ApiPropertyOptional({ + example: [ + { + locale: 'en', + name: 'Birth Certificate', + description: 'Official record of birth', + logo: { + uri: 'https://upload.wikimedia.org/wikipedia/commons/2/2f/ABC-2021-LOGO.svg', + alt_text: 'abc_logo' + } + }, + { + locale: 'ar', + name: 'شهادة الميلاد', + description: 'سجل رسمي للولادة', + logo: { + uri: 'https://upload.wikimedia.org/wikipedia/commons/2/2f/ABC-2021-LOGO.svg', + alt_text: 'شعار abc' + } + } + ] + }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CredentialDisplayDto) + display: CredentialDisplayDto[]; +} + +export enum SignerOption { + DID = 'did', + X509 = 'x509' +} +@ApiExtraModels(CredentialAttributeDto) +export class CreateCredentialTemplateDto { + @ApiProperty({ description: 'Template name' }) + @IsString() + name: string; + + @ApiProperty({ required: false, description: 'Optional description for the template' }) + @IsOptional() + @IsString() + description?: string; + + @ApiProperty({ + description: 'Signer option (did or x509)', + enum: SignerOption, + example: SignerOption.DID + }) + @IsEnum(SignerOption) + signerOption!: SignerOption; + + @ApiProperty({ enum: ['sd-jwt-vc', 'mdoc'], description: 'Credential format type' }) + @IsEnum(['sd-jwt-vc', 'mso_mdoc', 'vc+sd-jwt']) + format: 'sd-jwt-vc' | 'mso_mdoc ' | 'vc+sd-jwt'; + + @ApiProperty({ default: false, description: 'Indicates whether credentials can be revoked' }) + @IsBoolean() + canBeRevoked = false; + + @ApiProperty({ + type: 'object', + additionalProperties: { $ref: getSchemaPath(CredentialAttributeDto) }, + description: 'Attributes included in the credential template' + }) + @IsObject() + attributes: Record; + + @ApiProperty({ + type: Object, + required: false, + description: 'Appearance configuration for credentials (branding, colors, etc.)' + }) + @ApiPropertyOptional({ + type: AppearanceDto, + example: { + display: [ + { + locale: 'en', + name: 'Birth Certificate', + description: 'Official record of birth', + logo: { + uri: 'https://upload.wikimedia.org/wikipedia/commons/2/2f/ABC-2021-LOGO.svg', + alt_text: 'abc_logo' + } + }, + { + locale: 'ar', + name: 'شهادة الميلاد', + description: 'سجل رسمي للولادة', + logo: { + uri: 'https://upload.wikimedia.org/wikipedia/commons/2/2f/ABC-2021-LOGO.svg', + alt_text: 'شعار abc' + } + } + ] + } + }) + @IsOptional() + @ValidateNested() + @Type(() => AppearanceDto) + appearance?: AppearanceDto; + + issuerId: string; +} + +export class UpdateCredentialTemplateDto extends PartialType(CreateCredentialTemplateDto) {} diff --git a/apps/api-gateway/src/issuance/dtos/oidc-issuer.dto.ts b/apps/api-gateway/src/issuance/dtos/oidc-issuer.dto.ts new file mode 100644 index 000000000..89216fcb7 --- /dev/null +++ b/apps/api-gateway/src/issuance/dtos/oidc-issuer.dto.ts @@ -0,0 +1,289 @@ +/* eslint-disable camelcase */ +import { ApiExtraModels, ApiProperty, ApiPropertyOptional, getSchemaPath } from '@nestjs/swagger'; +import { + IsString, + IsOptional, + IsBoolean, + IsArray, + ValidateNested, + IsObject, + IsUrl, + IsNotEmpty, + IsDefined, + IsInt +} from 'class-validator'; +import { plainToInstance, Transform, Type } from 'class-transformer'; + +export class ClaimDto { + @ApiProperty({ + description: 'The unique key for the claim (e.g. email, name)', + example: 'email' + }) + @IsString() + key: string; + + @ApiProperty({ + description: 'The display label for the claim', + example: 'Email Address' + }) + @IsString() + label: string; + + @ApiProperty({ + description: 'Whether this claim is required for issuance', + example: true + }) + @IsBoolean() + required: boolean; +} + +export class LogoDto { + @ApiProperty({ + description: 'URI pointing to the logo image', + example: 'https://upload.wikimedia.org/wikipedia/commons/2/2f/ABC-2021-LOGO.svg' + }) + @IsUrl() + uri: string; + + @ApiProperty({ + description: 'Alternative text for the logo (for accessibility)', + example: 'ABC Company Logo' + }) + @IsString() + alt_text: string; +} + +export class DisplayDto { + @ApiProperty({ + description: 'The locale for display text', + example: 'en-US' + }) + @IsString() + locale: string; + + @ApiProperty({ + description: 'The display name for the credential/claim', + example: 'Student ID Card' + }) + @IsString({ message: 'Error from DisplayDto -> name' }) + name: string; + + @ApiPropertyOptional({ + description: 'A short description for the credential/claim', + example: 'Digital credential issued to enrolled students' + }) + @IsOptional() + @IsString() + description?: string; + + @ApiPropertyOptional({ + description: 'Logo display information for the issuer', + type: LogoDto + }) + @IsOptional() + @Type(() => LogoDto) + logo?: LogoDto; +} + +@ApiExtraModels(ClaimDto) +export class CredentialConfigurationDto { + @ApiProperty({ + description: 'The format of the credential', + example: 'jwt_vc_json' + }) + @IsString() + @IsDefined({ message: 'format field is required' }) + @IsNotEmpty({ message: 'format property is required' }) + format: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + vct?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + doctype?: string; + + @ApiProperty() + @IsString() + scope: string; + + // @ApiProperty({ + // description: 'List of claims supported in this credential', + // type: [ClaimDto], + // }) + // @IsArray() + // @ValidateNested({ each: true }) + // @Type(() => ClaimDto) + // claims: ClaimDto[] + @ApiProperty({ + description: 'Claims supported by this credential', + type: 'object', + additionalProperties: { $ref: getSchemaPath(ClaimDto) } + }) + @IsObject() + @ValidateNested({ each: true }) + @Transform(({ value }) => + Object.fromEntries(Object.entries(value || {}).map(([k, v]) => [k, plainToInstance(ClaimDto, v)])) + ) + claims: Record; + + @ApiProperty({ type: [String] }) + @IsArray() + credential_signing_alg_values_supported: string[]; + + @ApiProperty({ type: [String] }) + @IsArray() + cryptographic_binding_methods_supported: string[]; + + @ApiProperty({ + description: 'Localized display information for the credential', + type: [DisplayDto] + }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => DisplayDto) + display: DisplayDto[]; +} + +// export class AuthorizationServerConfigDto { +// @ApiProperty({ +// description: 'Authorization server issuer URL', +// example: 'https://auth.credebl.com', +// }) +// @IsUrl() +// issuer: string + +// @ApiPropertyOptional({ +// description: 'Token endpoint of the authorization server', +// example: 'https://auth.credebl.com/oauth/token', +// }) +// @IsOptional() +// @IsUrl() +// token_endpoint: string + +// @ApiProperty({ +// description: 'Authorization endpoint of the server', +// example: 'https://auth.credebl.com/oauth/authorize', +// }) +// @IsUrl() +// authorization_endpoint: string + +// @ApiProperty({ +// description: 'Supported scopes', +// example: ['openid', 'profile', 'email'], +// }) +// @IsArray() +// @IsString({ each: true }) +// scopes_supported: string[] +// } + +export class ClientAuthenticationDto { + @ApiProperty({ + description: 'OAuth2 client ID for the authorization server', + example: 'issuer-server' + }) + @IsString() + clientId: string; + + @ApiProperty({ + description: 'OAuth2 client secret for the authorization server', + example: '2qKMWulZpMBzXIdfPO5AEs0xaTaKs1uk' + }) + @IsString() + clientSecret: string; +} + +export class AuthorizationServerConfigDto { + @ApiProperty({ + description: 'Authorization server issuer URL', + example: 'https://example-oidc-provider.com' + }) + @IsString() + issuer: string; + + @ApiProperty({ + description: 'Client authentication configuration', + type: () => ClientAuthenticationDto + }) + @ValidateNested() + @Type(() => ClientAuthenticationDto) + clientAuthentication: ClientAuthenticationDto; +} + +export enum AccessTokenSignerKeyType { + ED25519 = 'ed25519' +} + +@ApiExtraModels(CredentialConfigurationDto) +export class IssuerCreationDto { + @ApiProperty({ + description: 'Name of the issuer', + example: 'Credebl University' + }) + @IsString({ message: 'issuerId from IssuerCreationDto -> issuerId, must be a string' }) + issuerId: string; + + @ApiPropertyOptional({ + description: 'Maximum number of credentials that can be issued in a batch', + example: 50, + type: Number + }) + @IsOptional() + @IsInt({ message: 'batchCredentialIssuanceSize must be an integer' }) + batchCredentialIssuanceSize?: number; + + @ApiProperty({ + description: 'Localized display information for the credential', + type: [DisplayDto] + }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => DisplayDto) + display: DisplayDto[]; + + @ApiProperty({ + description: 'Configuration of the authorization server', + type: AuthorizationServerConfigDto + }) + @ValidateNested() + @Type(() => AuthorizationServerConfigDto) + authorizationServerConfigs: AuthorizationServerConfigDto; +} + +export class IssuerUpdationDto { + issuerId?: string; + + @ApiProperty({ + description: 'accessTokenSignerKeyType', + example: 'ed25519' + }) + @IsString({ + message: 'accessTokenSignerKeyType from IssuerCreationDto -> accessTokenSignerKeyType, must be a string' + }) + accessTokenSignerKeyType: string; + + @ApiProperty({ + description: 'Localized display information for the credential', + type: [DisplayDto] + }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => DisplayDto) + display: DisplayDto[]; + + @ApiProperty({ + description: 'batchCredentialIssuanceSize', + example: '50' + }) + @ApiPropertyOptional({ + description: 'Maximum number of credentials that can be issued in a batch', + example: 50, + type: Number + }) + @IsOptional() + @IsInt({ message: 'batchCredentialIssuanceSize must be an integer' }) + batchCredentialIssuanceSize?: number; +} diff --git a/apps/api-gateway/src/issuance/issuance.module.ts b/apps/api-gateway/src/issuance/issuance.module.ts index d5f184d16..5a7df978c 100644 --- a/apps/api-gateway/src/issuance/issuance.module.ts +++ b/apps/api-gateway/src/issuance/issuance.module.ts @@ -8,6 +8,7 @@ import { getNatsOptions } from '@credebl/common/nats.config'; import { AwsService } from '@credebl/aws'; import { CommonConstants } from '@credebl/common/common.constant'; import { NATSClient } from '@credebl/common/NATSClient'; +import { OidcController } from './oidc.controller'; @Module({ imports: [ @@ -20,7 +21,7 @@ import { NATSClient } from '@credebl/common/NATSClient'; } ]) ], - controllers: [IssuanceController], + controllers: [IssuanceController, OidcController], providers: [IssuanceService, CommonService, AwsService, NATSClient] }) export class IssuanceModule {} diff --git a/apps/api-gateway/src/issuance/issuance.service.ts b/apps/api-gateway/src/issuance/issuance.service.ts index dd791f7e8..018285844 100644 --- a/apps/api-gateway/src/issuance/issuance.service.ts +++ b/apps/api-gateway/src/issuance/issuance.service.ts @@ -1,4 +1,6 @@ /* eslint-disable camelcase */ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +// TODO: Remove this import { Injectable, Inject } from '@nestjs/common'; import { ClientProxy } from '@nestjs/microservices'; import { BaseService } from 'libs/service/base.service'; @@ -26,8 +28,11 @@ import { IIssuedCredential } from '@credebl/common/interfaces/issuance.interface'; import { IssueCredentialDto } from './dtos/multi-connection.dto'; -import { user } from '@prisma/client'; +import { oidc_issuer, user } from '@prisma/client'; import { NATSClient } from '@credebl/common/NATSClient'; +import { CreateCredentialTemplateDto, UpdateCredentialTemplateDto } from './dtos/oidc-issuer-template.dto'; +import { CreateOidcCredentialOfferDto } from './dtos/issuer-sessions.dto'; +import { IssuerCreationDto, IssuerUpdationDto } from './dtos/oidc-issuer.dto'; @Injectable() export class IssuanceService extends BaseService { constructor( @@ -270,4 +275,91 @@ export class IssuanceService extends BaseService { }; return this.natsClient.sendNatsMessage(this.issuanceProxy, 'issued-file-data-and-file-details', payload); } + + // OIDC methods + + async oidcIssuerCreate( + issueCredentialDto: IssuerCreationDto, + orgId: string, + userDetails: user + ): Promise { + const payload = { issueCredentialDto, orgId, userDetails }; + return this.natsClient.sendNatsMessage(this.issuanceProxy, 'oidc-issuer-create', payload); + } + + async oidcIssuerUpdate(issueUpdationDto: IssuerUpdationDto, orgId: string, userDetails: user) { + const payload = { issueUpdationDto, orgId, userDetails }; + return this.natsClient.sendNatsMessage(this.issuanceProxy, 'oidc-issuer-update', payload); + } + + async oidcGetIssuerById(issuerId: string, orgId: string) { + const payload = { issuerId, orgId }; + return this.natsClient.sendNatsMessage(this.issuanceProxy, 'oidc-issuer-get-by-id', payload); + } + + async oidcGetIssuers(orgId: string) { + const payload = { orgId }; + return this.natsClient.sendNatsMessage(this.issuanceProxy, 'oidc-get-issuers', payload); + } + + async oidcDeleteIssuer(userDetails: user, orgId: string, issuerId: string) { + const payload = { issuerId, orgId, userDetails }; + return this.natsClient.sendNatsMessage(this.issuanceProxy, 'oidc-delete-issuer', payload); + } + + async deleteTemplate(userDetails: user, orgId: string, templateId: string) { + const payload = { templateId, orgId, userDetails }; + return this.natsClient.sendNatsMessage(this.issuanceProxy, 'oidc-template-delete', payload); + } + + async updateTemplate( + userDetails: user, + orgId: string, + templateId: string, + dto: UpdateCredentialTemplateDto, + issuerId: string + ) { + const payload = { templateId, orgId, userDetails, dto, issuerId }; + return this.natsClient.sendNatsMessage(this.issuanceProxy, 'oidc-template-update', payload); + } + + async findByIdTemplate(userDetails: user, orgId: string, templateId: string) { + const payload = { templateId, orgId, userDetails }; + return this.natsClient.sendNatsMessage(this.issuanceProxy, 'oidc-template-find-id', payload); + } + + async findAllTemplate(userDetails: user, orgId: string) { + const payload = { orgId, userDetails }; + return this.natsClient.sendNatsMessage(this.issuanceProxy, 'oidc-template-find-all', payload); + } + + async createTemplate( + CredentialTemplate: CreateCredentialTemplateDto, + userDetails: user, + orgId: string, + issuerId: string + ) { + const payload = { CredentialTemplate, orgId, userDetails, issuerId }; + return this.natsClient.sendNatsMessage(this.issuanceProxy, 'oidc-template-create', payload); + } + + async createOidcCredentialOffer( + oidcCredentialPayload: CreateOidcCredentialOfferDto, + userDetails: user, + orgId: string, + issuerId: string + ) { + const payload = { oidcCredentialPayload, orgId, userDetails, issuerId }; + return this.natsClient.sendNatsMessage(this.issuanceProxy, 'oidc-create-credential-offer', payload); + } + + oidcIssueCredentialWebhook( + oidcIssueCredentialDto, + id: string + ): Promise<{ + response: object; + }> { + const payload = { oidcIssueCredentialDto, id }; + return this.natsClient.sendNats(this.issuanceProxy, 'webhook-oidc-issue-credential', payload); + } } diff --git a/apps/api-gateway/src/issuance/oidc.controller.ts b/apps/api-gateway/src/issuance/oidc.controller.ts new file mode 100644 index 000000000..b37cc4cb4 --- /dev/null +++ b/apps/api-gateway/src/issuance/oidc.controller.ts @@ -0,0 +1,490 @@ +/* eslint-disable default-param-last */ +/* eslint-disable no-param-reassign */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable no-console */ +/* eslint-disable camelcase */ +import { + Controller, + Post, + Body, + UseGuards, + HttpStatus, + Res, + Get, + Param, + UseFilters, + BadRequestException, + ParseUUIDPipe, + Delete, + Patch +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, + ApiForbiddenResponse, + ApiUnauthorizedResponse, + ApiExcludeEndpoint +} from '@nestjs/swagger'; +import { AuthGuard } from '@nestjs/passport'; +import { ApiResponseDto } from '../dtos/apiResponse.dto'; +import { UnauthorizedErrorDto } from '../dtos/unauthorized-error.dto'; +import { ForbiddenErrorDto } from '../dtos/forbidden-error.dto'; +import { Response } from 'express'; +import IResponseType, { IResponse } from '@credebl/common/interfaces/response.interface'; +import { IssuanceService } from './issuance.service'; +import { User } from '../authz/decorators/user.decorator'; +import { ResponseMessages } from '@credebl/common/response-messages'; +import { Roles } from '../authz/decorators/roles.decorator'; +import { OrgRoles } from 'libs/org-roles/enums'; +import { OrgRolesGuard } from '../authz/guards/org-roles.guard'; +import { CustomExceptionFilter } from 'apps/api-gateway/common/exception-handler'; +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { user } from '@prisma/client'; +import { IssuerCreationDto, IssuerUpdationDto } from './dtos/oidc-issuer.dto'; +import { CreateCredentialTemplateDto, UpdateCredentialTemplateDto } from './dtos/oidc-issuer-template.dto'; +import { CreateOidcCredentialOfferDto } from './dtos/issuer-sessions.dto'; +import { IssuanceDto } from './dtos/issuance.dto'; +@Controller() +@UseFilters(CustomExceptionFilter) +@ApiTags('OIDC') +@ApiUnauthorizedResponse({ status: HttpStatus.UNAUTHORIZED, description: 'Unauthorized', type: UnauthorizedErrorDto }) +@ApiForbiddenResponse({ status: HttpStatus.FORBIDDEN, description: 'Forbidden', type: ForbiddenErrorDto }) +export class OidcController { + constructor(private readonly issueCredentialService: IssuanceService) {} + /** + * Create issuer against a org(tenant) + * @param orgId The ID of the organization + * @param user The user making the request + * @param res The response object + * @returns The status of the deletion operation + */ + @Post('/orgs/:orgId/oidc/issuers') + @ApiOperation({ summary: 'Create OIDC issuer', description: 'Create OIDC issuer by orgId' }) + @ApiResponse({ status: HttpStatus.OK, description: 'Success', type: ApiResponseDto }) + @ApiBearerAuth() + @Roles(OrgRoles.OWNER) + @UseGuards(AuthGuard('jwt'), OrgRolesGuard) + async oidcIssuerCreate( + @Param( + 'orgId', + new ParseUUIDPipe({ + exceptionFactory: (): Error => { + throw new BadRequestException(ResponseMessages.organisation.error.invalidOrgId); + } + }) + ) + orgId: string, + @User() user: user, + @Body() issueCredentialDto: IssuerCreationDto, + @Res() res: Response + ): Promise { + const createIssuer = await this.issueCredentialService.oidcIssuerCreate(issueCredentialDto, orgId, user); + const finalResponse: IResponse = { + statusCode: HttpStatus.CREATED, + message: ResponseMessages.oidcIssuer.success.issuerConfig, + data: createIssuer + }; + return res.status(HttpStatus.CREATED).json(finalResponse); + } + + @Post('/orgs/:orgId/oidc/issuers/:issuerId') + @ApiOperation({ summary: 'Update OIDC issuer', description: 'Update OIDC issuer by orgId' }) + @ApiResponse({ status: HttpStatus.OK, description: 'Success', type: ApiResponseDto }) + @ApiBearerAuth() + @Roles(OrgRoles.OWNER) + @UseGuards(AuthGuard('jwt'), OrgRolesGuard) + async oidcIssuerUpdate( + @Param( + 'orgId', + new ParseUUIDPipe({ + exceptionFactory: (): Error => { + throw new BadRequestException(ResponseMessages.organisation.error.invalidOrgId); + } + }) + ) + orgId: string, + @User() user: user, + @Param('issuerId') + issuerId: string, + @Body() issueCredentialDto: IssuerUpdationDto, + @Res() res: Response + ): Promise { + issueCredentialDto.issuerId = issuerId; + const createIssuer = await this.issueCredentialService.oidcIssuerUpdate(issueCredentialDto, orgId, user); + const finalResponse: IResponse = { + statusCode: HttpStatus.CREATED, + message: ResponseMessages.oidcIssuer.success.issuerConfigUpdate, + data: createIssuer + }; + return res.status(HttpStatus.CREATED).json(finalResponse); + } + + @Get('/orgs/:orgId/oidc/issuers/:issuerId') + @ApiOperation({ summary: 'Get OIDC issuer', description: 'Get OIDC issuer by orgId' }) + @ApiResponse({ status: HttpStatus.OK, description: 'Success', type: ApiResponseDto }) + @ApiBearerAuth() + @Roles(OrgRoles.OWNER) + @UseGuards(AuthGuard('jwt'), OrgRolesGuard) + async oidcGetIssuerById( + @Param( + 'orgId', + new ParseUUIDPipe({ + exceptionFactory: (): Error => { + throw new BadRequestException(ResponseMessages.organisation.error.invalidOrgId); + } + }) + ) + orgId: string, + @Param('issuerId') + issuerId: string, + @Res() res: Response + ): Promise { + const oidcIssuer = await this.issueCredentialService.oidcGetIssuerById(issuerId, orgId); + const finalResponse: IResponse = { + statusCode: HttpStatus.CREATED, + message: ResponseMessages.oidcIssuer.success.fetch, + data: oidcIssuer + }; + return res.status(HttpStatus.CREATED).json(finalResponse); + } + + @Get('/orgs/:orgId/oidc/issuers') + @ApiOperation({ summary: 'Get OIDC issuer', description: 'Get OIDC issuer by orgId' }) + @ApiResponse({ status: HttpStatus.OK, description: 'Success', type: ApiResponseDto }) + @ApiBearerAuth() + @Roles(OrgRoles.OWNER) + @UseGuards(AuthGuard('jwt'), OrgRolesGuard) + async oidcGetIssuers( + @Param( + 'orgId', + new ParseUUIDPipe({ + exceptionFactory: (): Error => { + throw new BadRequestException(ResponseMessages.organisation.error.invalidOrgId); + } + }) + ) + orgId: string, + @Res() res: Response + ): Promise { + const oidcIssuer = await this.issueCredentialService.oidcGetIssuers(orgId); + const finalResponse: IResponse = { + statusCode: HttpStatus.CREATED, + message: ResponseMessages.oidcIssuer.success.fetch, + data: oidcIssuer + }; + return res.status(HttpStatus.CREATED).json(finalResponse); + } + + @Delete('/orgs/:orgId/oidc/:issuerId') + @ApiBearerAuth() + @Roles(OrgRoles.OWNER) + @UseGuards(AuthGuard('jwt'), OrgRolesGuard) + @ApiOperation({ summary: 'Delete oidc issuer' }) + async deleteOidcIssuer( + @Param( + 'orgId', + new ParseUUIDPipe({ + exceptionFactory: (): Error => { + throw new BadRequestException(ResponseMessages.organisation.error.invalidOrgId); + } + }) + ) + orgId: string, + @Param( + 'issuerId', + new ParseUUIDPipe({ + exceptionFactory: (): Error => { + throw new BadRequestException(ResponseMessages.organisation.error.invalidOrgId); + } + }) + ) + issuerId: string, + @User() user: user, + @Res() res: Response + ): Promise { + await this.issueCredentialService.oidcDeleteIssuer(user, orgId, issuerId); + + const finalResponse: IResponse = { + statusCode: HttpStatus.OK, + message: ResponseMessages.oidcIssuer.success.delete + }; + + return res.status(HttpStatus.OK).json(finalResponse); + } + + @Post('/orgs/:orgId/oidc/:issuerId/template') + @ApiOperation({ summary: 'Create credential template' }) + @ApiResponse({ status: HttpStatus.CREATED, description: 'Template created successfully' }) + @ApiBearerAuth() + @Roles(OrgRoles.OWNER) + @UseGuards(AuthGuard('jwt'), OrgRolesGuard) + async createTemplate( + @Param('orgId') + orgId: string, + @Param('issuerId') + issuerId: string, + @User() user: user, + @Body() CredentialTemplate: CreateCredentialTemplateDto, + @Res() res: Response + ): Promise { + CredentialTemplate.issuerId = issuerId; + console.log('THis is dto', JSON.stringify(CredentialTemplate, null, 2)); + const template = await this.issueCredentialService.createTemplate(CredentialTemplate, user, orgId, issuerId); + + const finalResponse: IResponse = { + statusCode: HttpStatus.CREATED, + message: ResponseMessages.oidcTemplate.success.create, + data: template + }; + + return res.status(HttpStatus.CREATED).json(finalResponse); + } + + @Get('/orgs/:orgId/oidc/:issuerId/template') + @ApiOperation({ summary: 'List credential templates' }) + @ApiResponse({ status: HttpStatus.OK, description: 'List of templates' }) + @ApiBearerAuth() + @Roles(OrgRoles.OWNER) + @UseGuards(AuthGuard('jwt'), OrgRolesGuard) + async listTemplates( + @Param( + 'orgId', + new ParseUUIDPipe({ + exceptionFactory: (): Error => { + throw new BadRequestException(ResponseMessages.organisation.error.invalidOrgId); + } + }) + ) + orgId: string, + @Param( + 'issuerId', + new ParseUUIDPipe({ + exceptionFactory: (): Error => { + throw new BadRequestException(ResponseMessages.organisation.error.invalidOrgId); + } + }) + ) + issuerId: string, + @User() user: user, + @Res() res: Response + ): Promise { + const templates = await this.issueCredentialService.findAllTemplate(user, orgId, issuerId); + + const finalResponse: IResponse = { + statusCode: HttpStatus.OK, + message: ResponseMessages.oidcTemplate.success.fetch, + data: templates + }; + + return res.status(HttpStatus.OK).json(finalResponse); + } + + @Get('/orgs/:orgId/oidc/:issuerId/template/:templateId') + @ApiOperation({ summary: 'Get credential template by ID' }) + @ApiBearerAuth() + @Roles(OrgRoles.OWNER) + @UseGuards(AuthGuard('jwt'), OrgRolesGuard) + async getTemplateById( + @Param( + 'orgId', + new ParseUUIDPipe({ + exceptionFactory: (): Error => { + throw new BadRequestException(ResponseMessages.organisation.error.invalidOrgId); + } + }) + ) + orgId: string, + @Param( + 'issuerId', + new ParseUUIDPipe({ + exceptionFactory: (): Error => { + throw new BadRequestException(ResponseMessages.organisation.error.invalidOrgId); + } + }) + ) + issuerId: string, + @Param( + 'templateId', + new ParseUUIDPipe({ + exceptionFactory: (): Error => { + throw new BadRequestException(ResponseMessages.organisation.error.invalidOrgId); + } + }) + ) + templateId: string, + @User() user: user, + @Res() res: Response + ): Promise { + const template = await this.issueCredentialService.findByIdTemplate(user, orgId, templateId, issuerId); + + const finalResponse: IResponse = { + statusCode: HttpStatus.OK, + message: ResponseMessages.oidcTemplate.success.fetch, + data: template + }; + + return res.status(HttpStatus.OK).json(finalResponse); + } + + @Patch('/orgs/:orgId/oidc/:issuerId/template/:templateId') + @ApiBearerAuth() + @Roles(OrgRoles.OWNER) + @UseGuards(AuthGuard('jwt'), OrgRolesGuard) + @ApiOperation({ summary: 'Update credential template' }) + async updateTemplate( + @Param( + 'orgId', + new ParseUUIDPipe({ + exceptionFactory: (): Error => { + throw new BadRequestException(ResponseMessages.organisation.error.invalidOrgId); + } + }) + ) + orgId: string, + @Param( + 'issuerId', + new ParseUUIDPipe({ + exceptionFactory: (): Error => { + throw new BadRequestException(ResponseMessages.organisation.error.invalidOrgId); + } + }) + ) + issuerId: string, + @Param( + 'templateId', + new ParseUUIDPipe({ + exceptionFactory: (): Error => { + throw new BadRequestException(ResponseMessages.organisation.error.invalidOrgId); + } + }) + ) + templateId: string, + @User() user: user, + @Body() dto: UpdateCredentialTemplateDto, + @Res() res: Response + ): Promise { + const updated = await this.issueCredentialService.updateTemplate(user, orgId, templateId, dto, issuerId); + + const finalResponse: IResponse = { + statusCode: HttpStatus.OK, + message: ResponseMessages.oidcTemplate.success.update, + data: updated + }; + + return res.status(HttpStatus.OK).json(finalResponse); + } + + @Delete('/orgs/:orgId/oidc/:issuerId/template/:templateId') + @ApiBearerAuth() + @Roles(OrgRoles.OWNER) + @UseGuards(AuthGuard('jwt'), OrgRolesGuard) + @ApiOperation({ summary: 'Delete credential template' }) + async deleteTemplate( + @Param( + 'orgId', + new ParseUUIDPipe({ + exceptionFactory: (): Error => { + throw new BadRequestException(ResponseMessages.organisation.error.invalidOrgId); + } + }) + ) + orgId: string, + @Param( + 'issuerId', + new ParseUUIDPipe({ + exceptionFactory: (): Error => { + throw new BadRequestException(ResponseMessages.organisation.error.invalidOrgId); + } + }) + ) + issuerId: string, + @Param( + 'templateId', + new ParseUUIDPipe({ + exceptionFactory: (): Error => { + throw new BadRequestException(ResponseMessages.organisation.error.invalidOrgId); + } + }) + ) + templateId: string, + @User() user: user, + @Res() res: Response + ): Promise { + await this.issueCredentialService.deleteTemplate(user, orgId, templateId, issuerId); + + const finalResponse: IResponse = { + statusCode: HttpStatus.OK, + message: ResponseMessages.oidcTemplate.success.delete + }; + + return res.status(HttpStatus.OK).json(finalResponse); + } + + @Post('/orgs/:orgId/oidc/:issuerId/create-offer') + @ApiOperation({ summary: 'Create OIDC Credential Offer' }) + @ApiResponse({ + status: HttpStatus.CREATED, + description: `This endpoint creates a new OIDC4VCI credential-offer for a given issuer. It allows clients to request issuance of credentials (e.g., Birth Certificate, Driving License, Student ID) from a registered OIDC issuer using the issuer's ID.` + }) + // @ApiBearerAuth() + // @Roles(OrgRoles.OWNER) + // @UseGuards(AuthGuard('jwt'), OrgRolesGuard) + async createOidcCredentialOffer( + @Param('orgId') + orgId: string, + @Param('issuerId') + issuerId: string, + @User() user: user, + @Body() oidcCredentialPayload: CreateOidcCredentialOfferDto, + @Res() res: Response + ): Promise { + oidcCredentialPayload.issuerId = issuerId; + const template = await this.issueCredentialService.createOidcCredentialOffer( + oidcCredentialPayload, + user, + orgId, + issuerId + ); + const finalResponse: IResponse = { + statusCode: HttpStatus.CREATED, + message: ResponseMessages.oidcTemplate.success.create, + data: template + }; + + return res.status(HttpStatus.CREATED).json(finalResponse); + } + + /** + * Catch issue credential webhook responses + * @param oidcIssueCredentialDto The details of the oidc issued credential + * @param id The ID of the organization + * @param res The response object + * @returns The details of the oidc issued credential + */ + @Post('wh/:id/credentials') + @ApiExcludeEndpoint() + @ApiOperation({ + summary: 'Catch OIDC credential states', + description: 'Catch OIDC credential states' + }) + async getIssueCredentialWebhook( + @Body() oidcIssueCredentialDto, + @Param('id') id: string, + @Res() res: Response + ): Promise { + const getCredentialDetails = await this.issueCredentialService.oidcIssueCredentialWebhook( + oidcIssueCredentialDto, + id + ); + + const finalResponse: IResponseType = { + statusCode: HttpStatus.CREATED, + message: ResponseMessages.issuance.success.create, + data: getCredentialDetails + }; + + return res.status(HttpStatus.CREATED).json(finalResponse); + } +} diff --git a/apps/issuance/constant/issuance.ts b/apps/issuance/constant/issuance.ts new file mode 100644 index 000000000..a39a160ca --- /dev/null +++ b/apps/issuance/constant/issuance.ts @@ -0,0 +1,6 @@ +import { AccessTokenSignerKeyType } from '../interfaces/oidc-issuance.interfaces'; + +export const dpopSigningAlgValuesSupported = ['RS256', 'ES256', 'EdDSA']; +export const credentialConfigurationsSupported = {}; +export const accessTokenSignerKeyType = 'ed25519' as AccessTokenSignerKeyType; +export const batchCredentialIssuanceDefault = 0; diff --git a/apps/issuance/interfaces/oidc-issuance.interfaces.ts b/apps/issuance/interfaces/oidc-issuance.interfaces.ts new file mode 100644 index 000000000..854480f70 --- /dev/null +++ b/apps/issuance/interfaces/oidc-issuance.interfaces.ts @@ -0,0 +1,147 @@ +export interface Claim { + key: string; + label: string; + required: boolean; +} + +export interface Logo { + uri: string; + alt_text: string; +} + +export interface Display { + locale: string; + name: string; + description?: string; + logo?: Logo; +} + +export interface CredentialConfiguration { + format: string; + vct?: string; + doctype?: string; + scope: string; + claims: Record; + credential_signing_alg_values_supported: string[]; + cryptographic_binding_methods_supported: string[]; + display: Display[]; +} + +export interface ClientAuthentication { + clientId: string; + clientSecret: string; +} + +export interface AuthorizationServerConfig { + issuer: string; + clientAuthentication: ClientAuthentication; +} + +export interface IssuerCreation { + issuerId: string; + accessTokenSignerKeyType?: AccessTokenSignerKeyType; + display: Display[]; + dpopSigningAlgValuesSupported?: string[]; + credentialConfigurationsSupported?: Record; + authorizationServerConfigs: AuthorizationServerConfig; + batchCredentialIssuanceSize: number; +} + +export interface IssuerInitialConfig { + issuerId: string; + // eslint-disable-next-line @typescript-eslint/ban-types + display: Display[] | {}; + // eslint-disable-next-line @typescript-eslint/ban-types + authorizationServerConfigs: AuthorizationServerConfig | {}; + accessTokenSignerKeyType: AccessTokenSignerKeyType; + dpopSigningAlgValuesSupported: string[]; + batchCredentialIssuance: object; + credentialConfigurationsSupported: object; +} + +export interface IssuerMetadata { + publicIssuerId: string; + createdById: string; + orgAgentId: string; + batchCredentialIssuanceSize?: number; +} + +export interface initialIssuerDetails { + metadata: Display[]; + publicIssuerId: string; +} + +export enum AccessTokenSignerKeyType { + ED25519 = 'ed25519' +} + +export interface IssuerUpdation { + issuerId: string; + accessTokenSignerKeyType: AccessTokenSignerKeyType; + display; + batchCredentialIssuanceSize?: number; +} + +export interface IAgentNatsPayload { + url: string; + apiKey?: string; + orgId?: string; +} + +export type IAgentOIDCIssuerCreate = IAgentNatsPayload & { issuerCreation: IssuerCreation }; + +export interface TagMap { + [key: string]: string; +} + +export interface ClaimDisplay { + name: string; + locale: string; + description?: string; +} + +export interface ClaimDefinition { + value_type: string; + mandatory: boolean; + display: ClaimDisplay[]; +} +export interface Logo { + uri: string; + alt_text: string; +} + +export interface DisplayInfo { + logo?: Logo; + name: string; + locale: string; + description: string; +} + +export interface ClientAuthentication { + clientId: string; + clientSecret: string; +} + +export interface AuthorizationServerConfig { + issuer: string; + clientAuthentication: ClientAuthentication; +} + +export interface BatchCredentialIssuance { + batchSize: number; +} + +export interface IssuerResponse { + _tags: TagMap; + metadata: Record; + id: string; + createdAt: string; + issuerId: string; + accessTokenPublicKeyFingerprint: string; + credentialConfigurationsSupported?: Record; + dpopSigningAlgValuesSupported: string[]; + display?: DisplayInfo[]; + authorizationServerConfigs?: AuthorizationServerConfig[]; + batchCredentialIssuance: BatchCredentialIssuance; + updatedAt: string; +} diff --git a/apps/issuance/interfaces/oidc-issuer-sessions.interfaces.ts b/apps/issuance/interfaces/oidc-issuer-sessions.interfaces.ts new file mode 100644 index 000000000..e6db80dc0 --- /dev/null +++ b/apps/issuance/interfaces/oidc-issuer-sessions.interfaces.ts @@ -0,0 +1,51 @@ +/* --------------------------------------------------------- + * Enums + * --------------------------------------------------------- */ +export enum CredentialFormat { + SdJwtVc = 'vc+sd-jwt', + MsoMdoc = 'mso_mdoc' +} + +export enum SignerMethodOption { + DID = 'did', + X5C = 'x5c' +} + +export interface SignerOption { + method: SignerMethodOption; + did?: string; + x5c?: string[]; +} + +export enum AuthenticationType { + PRE_AUTHORIZED_CODE = 'pre-authorized_code', + AUTHORIZATION_CODE = 'authorization_code' +} + +/* --------------------------------------------------------- + * Interfaces + * --------------------------------------------------------- */ +export type DisclosureFrame = Record>; + +export interface CredentialPayload { + full_name?: string; + birth_date?: string; // YYYY-MM-DD if present + birth_place?: string; + parent_names?: string; + [key: string]: unknown; // extensible for mDoc or other formats +} + +export interface CredentialRequest { + credentialSupportedId: string; + templateId: string; + format: CredentialFormat; // "vc+sd-jwt" | "mso_mdoc" + payload: CredentialPayload; // user-supplied payload (without vct) + disclosureFrame?: DisclosureFrame; // only relevant for vc+sd-jwt +} + +export interface CreateOidcCredentialOffer { + // e.g. "abc-gov" + // signerMethod: SignerMethodOption; // only option selector + authenticationType: AuthenticationType; // only option selector + credentials: CredentialRequest[]; // one or more credentials +} diff --git a/apps/issuance/interfaces/oidc-template.interface.ts b/apps/issuance/interfaces/oidc-template.interface.ts new file mode 100644 index 000000000..2025cfecd --- /dev/null +++ b/apps/issuance/interfaces/oidc-template.interface.ts @@ -0,0 +1,26 @@ +import { Prisma } from '@prisma/client'; +import { Display } from './oidc-issuance.interfaces'; + +export interface CredentialAttribute { + mandatory?: boolean; + value_type: string; + display?: Display[]; +} + +export enum SignerOption { + DID = 'did', + X509 = 'x509' +} +export interface CreateCredentialTemplate { + name: string; + description?: string; + signerOption?: SignerOption; + format: 'sd-jwt-vc' | 'mdoc'; + issuer: string; + canBeRevoked: boolean; + attributes: Prisma.JsonValue; + appearance?: Prisma.JsonValue; + issuerId: string; +} + +export interface UpdateCredentialTemplate extends Partial {} diff --git a/apps/issuance/libs/helpers/credential-sessions.builder.ts b/apps/issuance/libs/helpers/credential-sessions.builder.ts new file mode 100644 index 000000000..6764e0634 --- /dev/null +++ b/apps/issuance/libs/helpers/credential-sessions.builder.ts @@ -0,0 +1,271 @@ +// builder/credential-offer.builder.ts +/* eslint-disable @typescript-eslint/naming-convention, @typescript-eslint/explicit-function-return-type, @typescript-eslint/explicit-module-boundary-types, camelcase */ +import { Prisma, credential_templates } from '@prisma/client'; + +/* ============================================================================ + Domain Types +============================================================================ */ + +type ValueType = 'string' | 'date' | 'number' | 'boolean' | string; + +interface TemplateAttribute { + display: { name: string; locale: string }[]; + mandatory: boolean; + value_type: ValueType; +} +type TemplateAttributes = Record; + +export enum CredentialFormat { + SdJwtVc = 'vc+sd-jwt', + Mdoc = 'mdoc' +} + +export enum SignerMethodOption { + DID = 'did', + X5C = 'x5c' +} + +export type DisclosureFrame = Record>; + +export interface CredentialRequestDtoLike { + /** maps to credential_templates.id (the template to use) */ + templateId: string; + /** per-template claims */ + payload: Record; + /** optional selective disclosure map */ + disclosureFrame?: DisclosureFrame; +} + +export interface CreateOidcCredentialOfferDtoLike { + credentials: CredentialRequestDtoLike[]; + + // Exactly one of the two must be provided (XOR) + preAuthorizedCodeFlowConfig?: { + txCode: { description?: string; length: number; input_mode: 'numeric' | 'text' | 'alphanumeric' }; + authorizationServerUrl: string; + }; + authorizationCodeFlowConfig?: { + authorizationServerUrl: string; + }; + + // NOTE: issuerId is intentionally NOT emitted in the final payload + publicIssuerId?: string; +} + +export interface ResolvedSignerOption { + method: 'did' | 'x5c'; + did?: string; + x5c?: string[]; +} + +/* ============================================================================ + Strong return types +============================================================================ */ + +export interface BuiltCredential { + /** e.g., "BirthCertificateCredential-sdjwt" or "DrivingLicenseCredential-mdoc" */ + credentialSupportedId: string; + /** Derived from template.format ("vc+sd-jwt" | "mdoc") */ + format: CredentialFormat; + /** User-provided payload (validated, with vct removed) */ + payload: Record; + /** Optional disclosure frame (usually for SD-JWT) */ + disclosureFrame?: DisclosureFrame; +} + +export interface BuiltCredentialOfferBase { + /** Resolved signer option (DID or x5c) */ + signerOption: ResolvedSignerOption; + /** Normalized credential entries */ + credentials: BuiltCredential[]; + /** Optional public issuer id to include */ + publicIssuerId?: string; +} + +/** Final payload = base + EXACTLY ONE of the two flows */ +export type CredentialOfferPayload = BuiltCredentialOfferBase & + ( + | { + preAuthorizedCodeFlowConfig: { + txCode: { description?: string; length: number; input_mode: 'numeric' | 'text' | 'alphanumeric' }; + authorizationServerUrl: string; + }; + authorizationCodeFlowConfig?: never; + } + | { + authorizationCodeFlowConfig: { + authorizationServerUrl: string; + }; + preAuthorizedCodeFlowConfig?: never; + } + ); + +/* ============================================================================ + Small Utilities +============================================================================ */ + +const isNil = (v: unknown): v is null | undefined => null == v; +const isEmptyString = (v: unknown): boolean => 'string' === typeof v && '' === v.trim(); +const isRecord = (v: unknown): v is Record => Boolean(v) && 'object' === typeof v && !Array.isArray(v); + +/** Map DB format string -> API enum */ +function mapDbFormatToApiFormat(db: string): CredentialFormat { + const norm = db.toLowerCase().replace(/_/g, '-'); + if ('sd-jwt' === norm || 'vc+sd-jwt' === norm || 'sdjwt' === norm) { + return CredentialFormat.SdJwtVc; + } + if ('mdoc' === norm || 'mso-mdoc' === norm || 'mso-mdoc' === norm) { + return CredentialFormat.Mdoc; + } + throw new Error(`Unsupported template format: ${db}`); +} + +/** Map API enum -> id suffix required for credentialSupportedId */ +function formatSuffix(api: CredentialFormat): 'sdjwt' | 'mdoc' { + return api === CredentialFormat.SdJwtVc ? 'sdjwt' : 'mdoc'; +} + +/* ============================================================================ + Validation of Payload vs Template Attributes +============================================================================ */ + +/** Throw if any template-mandatory claim is missing/empty in payload. */ +function assertMandatoryClaims( + payload: Record, + attributes: TemplateAttributes, + ctx: { templateId: string } +): void { + const missing: string[] = []; + for (const [claim, def] of Object.entries(attributes)) { + if (!def?.mandatory) { + continue; + } + const val = payload[claim]; + if (isNil(val) || isEmptyString(val)) { + missing.push(claim); + } + } + if (missing.length) { + throw new Error(`Missing mandatory claims for template "${ctx.templateId}": ${missing.join(', ')}`); + } +} + +/* ============================================================================ + JsonValue → TemplateAttributes Narrowing (Type Guards) +============================================================================ */ + +function isDisplayArray(v: unknown): v is { name: string; locale: string }[] { + return Array.isArray(v) && v.every((d) => isRecord(d) && 'string' === typeof d.name && 'string' === typeof d.locale); +} + +function isTemplateAttribute(v: unknown): v is TemplateAttribute { + return ( + isRecord(v) && isDisplayArray(v.display) && 'boolean' === typeof v.mandatory && 'string' === typeof v.value_type + ); +} + +/** Accept `unknown` so predicate type (TemplateAttributes) is assignable to parameter type. */ +function isTemplateAttributes(v: unknown): v is TemplateAttributes { + if (!isRecord(v)) { + return false; + } + return Object.values(v).every(isTemplateAttribute); +} + +/** Runtime assert + narrow Prisma.JsonValue → TemplateAttributes */ +function ensureTemplateAttributes(v: Prisma.JsonValue): TemplateAttributes { + if (!isTemplateAttributes(v)) { + throw new Error('Invalid template.attributes shape. Expecting TemplateAttributes map.'); + } + return v; +} + +/* ============================================================================ + Builders +============================================================================ */ + +/** Build one credential block normalized to API format (using the template's format). */ +function buildOneCredential( + cred: CredentialRequestDtoLike, + template: credential_templates, + attrs: TemplateAttributes +): BuiltCredential { + // 1) Validate payload against template attributes + assertMandatoryClaims(cred.payload, attrs, { templateId: cred.templateId }); + + // 2) Decide API format from DB format + const apiFormat = mapDbFormatToApiFormat(template.format); + + // 3) Build supportedId from template.name + suffix ("-sdjwt" | "-mdoc") + const suffix = formatSuffix(apiFormat); + const credentialSupportedId = `${template.name}-${suffix}`; + + // 4) Strip vct ALWAYS (per requirement) + const payload = { ...(cred.payload as Record) }; + delete (payload as Record).vct; + + return { + credentialSupportedId, // e.g., "BirthCertificateCredential-sdjwt" + format: apiFormat, // 'vc+sd-jwt' | 'mdoc' + payload, // without vct + ...(cred.disclosureFrame ? { disclosureFrame: cred.disclosureFrame } : {}) + }; +} + +/** + * Build the full OIDC credential offer payload. + * - Verifies template IDs + * - Validates mandatory claims per template + * - Normalizes formats & IDs + * - Enforces XOR of flow configs + * - Removes issuerId from the final envelope + * - Removes vct from all payloads + * - Sets credentialSupportedId = "-sdjwt|mdoc" + */ +export function buildCredentialOfferPayload( + dto: CreateOidcCredentialOfferDtoLike, + templates: credential_templates[], + signerOption: ResolvedSignerOption +): CredentialOfferPayload { + // Index templates + const byId = new Map(templates.map((t) => [t.id, t])); + + // Verify all requested templateIds exist + const unknown = dto.credentials.map((c) => c.templateId).filter((id) => !byId.has(id)); + if (unknown.length) { + throw new Error(`Unknown template ids: ${unknown.join(', ')}`); + } + + // Build credentials + const credentials: BuiltCredential[] = dto.credentials.map((cred) => { + const template = byId.get(cred.templateId)!; + const attrs = ensureTemplateAttributes(template.attributes); // narrow JsonValue safely + return buildOneCredential(cred, template, attrs); + }); + + // --- Base envelope (issuerId deliberately NOT included) --- + const base: BuiltCredentialOfferBase = { + signerOption, // resolved keys (did/x5c) from DB + credentials, + ...(dto.publicIssuerId ? { publicIssuerId: dto.publicIssuerId } : {}) + }; + + // XOR flow selection (defensive) + const hasPre = Boolean(dto.preAuthorizedCodeFlowConfig); + const hasAuth = Boolean(dto.authorizationCodeFlowConfig); + if (hasPre === hasAuth) { + throw new Error('Provide exactly one of preAuthorizedCodeFlowConfig or authorizationCodeFlowConfig.'); + } + + if (hasPre) { + return { + ...base, + preAuthorizedCodeFlowConfig: dto.preAuthorizedCodeFlowConfig! // definite since hasPre + }; + } + + return { + ...base, + authorizationCodeFlowConfig: dto.authorizationCodeFlowConfig! // definite since !hasPre + }; +} diff --git a/apps/issuance/libs/helpers/issuer.metadata.ts b/apps/issuance/libs/helpers/issuer.metadata.ts new file mode 100644 index 000000000..f9950f807 --- /dev/null +++ b/apps/issuance/libs/helpers/issuer.metadata.ts @@ -0,0 +1,244 @@ +/* eslint-disable camelcase */ +import { oidc_issuer, Prisma } from '@prisma/client'; +import { batchCredentialIssuanceDefault } from '../../constant/issuance'; +import { CreateOidcCredentialOffer } from 'apps/issuance/interfaces/oidc-issuer-sessions.interfaces'; + +type AttributeDisplay = { name: string; locale: string }; +type AttributeDef = { + display?: AttributeDisplay[]; + mandatory?: boolean; + value_type: 'string' | 'date' | 'number' | 'boolean' | string; +}; +type AttributesMap = Record; + +type CredentialDisplayItem = { + logo?: { uri: string; alt_text?: string }; + name: string; + locale?: string; + description?: string; +}; +type Appearance = { + display: CredentialDisplayItem[]; +}; + +type CredentialConfig = { + format: string; + vct?: string; + scope: string; + doctype?: string; + claims: Record< + string, + { + mandatory?: boolean; + value_type: string; + display?: AttributeDisplay[]; + } + >; + credential_signing_alg_values_supported: string[]; + cryptographic_binding_methods_supported: string[]; + display: { name: string; description?: string; locale?: string }[]; +}; + +type CredentialConfigurationsSupported = { + credentialConfigurationsSupported: Record; +}; + +// ---- Static Lists (as requested) ---- +const STATIC_CREDENTIAL_ALGS = ['ES256', 'EdDSA'] as const; +const STATIC_BINDING_METHODS = ['did:key'] as const; +const DOCTYPE = 'org.iso.18013.5.1'; // for mso_mdoc format +const MSO_MDOC = 'mso_mdoc'; // alternative format value + +// Safe coercion helpers +function coerceJsonObject(v: Prisma.JsonValue): T | null { + if (null == v) { + return null; + } + if ('string' === typeof v) { + try { + return JSON.parse(v) as T; + } catch { + return null; + } + } + return v as unknown as T; // already a JsonObject/JsonArray +} +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function isAttributesMap(x: any): x is AttributesMap { + return x && 'object' === typeof x && !Array.isArray(x); +} +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function isAppearance(x: any): x is Appearance { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return x && 'object' === typeof x && Array.isArray((x as any).display); +} + +// Prisma row shape +type TemplateRowPrisma = { + id: string; + name: string; + description?: string | null; + format?: string | null; + canBeRevoked?: boolean | null; + attributes: Prisma.JsonValue; // JsonValue from DB + appearance: Prisma.JsonValue; // JsonValue from DB + issuerId: string; + createdAt?: Date | string; + updatedAt?: Date | string; +}; + +/** + * Build agent payload from Prisma rows (attributes/appearance are Prisma.JsonValue). + * Safely coerces JSON and then builds the same structure as Builder #2. + */ +export function buildCredentialConfigurationsSupported( + templates: TemplateRowPrisma[], + opts?: { + vct?: string; + scopeVct?: string; + keyResolver?: (t: TemplateRowPrisma) => string; + format?: string; + } +): CredentialConfigurationsSupported { + const format = opts?.format ?? 'vc+sd-jwt'; + const credentialConfigurationsSupported: Record = {}; + for (const t of templates) { + // Coerce JSON fields + const attrs = coerceJsonObject(t.attributes); + const app = coerceJsonObject(t.appearance); + + if (!isAttributesMap(attrs)) { + throw new Error(`Template ${t.id}: invalid attributes JSON`); + } + if (!isAppearance(app)) { + throw new Error(`Template ${t.id}: invalid appearance JSON (missing display)`); + } + + // ---- dynamic format per row ---- + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const rowFormat = (t as any).format ?? format; + const suffix = rowFormat === `${MSO_MDOC}` ? 'mdoc' : 'sdjwt'; + + // key: keep your keyResolver override; otherwise include suffix + const key = 'function' === typeof opts?.keyResolver ? opts.keyResolver(t) : `${t.name}-${suffix}`; + + // vct/scope: vct only for non-mdoc; scope always uses suffix + const vct = opts?.vct ?? t.name; + const scopeBase = opts?.scopeVct ?? vct; + const scope = `openid4vc:credential:${scopeBase}-${suffix}`; + const claims = Object.fromEntries( + Object.entries(attrs).map(([claimName, def]) => { + const d = def as AttributeDef; + return [ + claimName, + { + value_type: d.value_type, + mandatory: d.mandatory ?? false, // always include, default to false + display: Array.isArray(d.display) ? d.display.map((x) => ({ name: x.name, locale: x.locale })) : undefined + } + ]; + }) + ); + + const display = + app.display.map((d) => ({ + name: d.name, + description: d.description, + locale: d.locale + })) ?? []; + + // assemble per-template config + credentialConfigurationsSupported[key] = { + format: rowFormat, + scope, + claims, + credential_signing_alg_values_supported: [...STATIC_CREDENTIAL_ALGS], + cryptographic_binding_methods_supported: [...STATIC_BINDING_METHODS], + display, + ...(rowFormat === `${MSO_MDOC}` + ? { doctype: `${DOCTYPE}` } // static for mdoc + : { vct }) // keep vct only for non-mdoc + }; + } + + return { + credentialConfigurationsSupported + }; +} + +// Default DPoP list for issuer-level metadata (match your example) +const ISSUER_DPOP_ALGS_DEFAULT = ['RS256', 'ES256'] as const; + +// ---------- Safe coercion ---------- +function coerceJson(v: Prisma.JsonValue): T | null { + if (null == v) { + return null; + } + if ('string' === typeof v) { + try { + return JSON.parse(v) as T; + } catch { + return null; + } + } + return v as unknown as T; +} + +type DisplayItem = { + name: string; + locale?: string; + description?: string; + logo?: { uri: string; alt_text?: string }; +}; + +function isDisplayArray(x: unknown): x is DisplayItem[] { + return ( + Array.isArray(x) && + x.every( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (i) => i && 'object' === typeof i && 'string' === typeof (i as any).name + ) + ); +} + +// ---------- Builder you asked for ---------- +/** + * Build issuer metadata payload from issuer row + credential configurations. + * + * @param credentialConfigurations Object with credentialConfigurationsSupported (from your template builder) + * @param oidcIssuer OIDC issuer row (uses publicIssuerId and metadata -> display) + * @param opts Optional overrides: dpopAlgs[], accessTokenSignerKeyType + */ +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export function buildIssuerPayload( + credentialConfigurations: CredentialConfigurationsSupported, + oidcIssuer: oidc_issuer, + opts?: { + dpopAlgs?: string[]; + accessTokenSignerKeyType?: string; + } +) { + if (!oidcIssuer?.publicIssuerId || 'string' !== typeof oidcIssuer.publicIssuerId) { + throw new Error('Invalid issuer: missing publicIssuerId'); + } + + const rawDisplay = coerceJson(oidcIssuer.metadata); + const display: DisplayItem[] = isDisplayArray(rawDisplay) ? rawDisplay : []; + + return { + display, + dpopSigningAlgValuesSupported: opts?.dpopAlgs ?? [...ISSUER_DPOP_ALGS_DEFAULT], + credentialConfigurationsSupported: credentialConfigurations.credentialConfigurationsSupported ?? {}, + batchCredentialIssuance: { + batchSize: oidcIssuer?.batchCredentialIssuanceSize ?? batchCredentialIssuanceDefault + } + }; +} + +export function extractTemplateIds(offer: CreateOidcCredentialOffer): string[] { + if (!offer?.credentials || !Array.isArray(offer.credentials)) { + return []; + } + + return offer.credentials.map((c) => c.templateId).filter((id): id is string => Boolean(id)); +} diff --git a/apps/issuance/src/issuance.controller.ts b/apps/issuance/src/issuance.controller.ts index a1c0ade71..6538c39fd 100644 --- a/apps/issuance/src/issuance.controller.ts +++ b/apps/issuance/src/issuance.controller.ts @@ -1,3 +1,5 @@ +/* eslint-disable @typescript-eslint/naming-convention, @typescript-eslint/explicit-function-return-type, @typescript-eslint/explicit-module-boundary-types, camelcase */ +// TODO: Remove this import { IClientDetails, IIssuance, @@ -19,11 +21,18 @@ import { Controller } from '@nestjs/common'; import { IssuanceService } from './issuance.service'; import { MessagePattern } from '@nestjs/microservices'; import { OOBIssueCredentialDto } from 'apps/api-gateway/src/issuance/dtos/issuance.dto'; -import { user } from '@prisma/client'; +import { credential_templates, oidc_issuer, user } from '@prisma/client'; +import { CreateCredentialTemplate, UpdateCredentialTemplate } from '../interfaces/oidc-template.interface'; +import { IssuerCreation, IssuerUpdation } from '../interfaces/oidc-issuance.interfaces'; +import { CreateOidcCredentialOffer } from '../interfaces/oidc-issuer-sessions.interfaces'; +import { OIDCIssuanceService } from './oidc-issuance.service'; @Controller() export class IssuanceController { - constructor(private readonly issuanceService: IssuanceService) {} + constructor( + private readonly issuanceService: IssuanceService, + private readonly oidcIssuanceService: OIDCIssuanceService + ) {} @MessagePattern({ cmd: 'get-issuance-records' }) async getIssuanceRecordsByOrgId(payload: { orgId: string; userId: string }): Promise { @@ -133,4 +142,121 @@ export class IssuanceController { async getFileDetailsAndFileDataByFileId(payload: { fileId: string; orgId: string }): Promise { return this.issuanceService.getFileDetailsAndFileDataByFileId(payload.fileId, payload.orgId); } + + @MessagePattern({ cmd: 'oidc-issuer-create' }) + async oidcIssuerCreate(payload: { + issueCredentialDto: IssuerCreation; + orgId: string; + userDetails: user; + }): Promise { + const { issueCredentialDto, orgId, userDetails } = payload; + return this.oidcIssuanceService.oidcIssuerCreate(issueCredentialDto, orgId, userDetails); + } + + @MessagePattern({ cmd: 'oidc-issuer-update' }) + async oidcIssuerUpdate(payload: { + issueUpdationDto: IssuerUpdation; + orgId: string; + userDetails: user; + }): Promise { + const { issueUpdationDto, orgId, userDetails } = payload; + return this.oidcIssuanceService.oidcIssuerUpdate(issueUpdationDto, orgId, userDetails); + } + + @MessagePattern({ cmd: 'oidc-issuer-get-by-id' }) + async oidcGetIssuerById(payload: { + issuerId: string; + orgId: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }): Promise { + const { issuerId, orgId } = payload; + return this.oidcIssuanceService.oidcIssuerGetById(issuerId, orgId); + } + + @MessagePattern({ cmd: 'oidc-get-issuers' }) + async oidcGetIssuers(payload: { orgId: string }): Promise { + const { orgId } = payload; + return this.oidcIssuanceService.oidcIssuers(orgId); + } + + @MessagePattern({ cmd: 'oidc-delete-issuer' }) + async deleteOidcIssuer(payload: { + orgId: string; + userDetails: user; + issuerId: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }): Promise { + const { orgId, userDetails, issuerId } = payload; + return this.oidcIssuanceService.deleteOidcIssuer(orgId, userDetails, issuerId); + } + + @MessagePattern({ cmd: 'oidc-template-create' }) + async oidcTemplateCreate(payload: { + CredentialTemplate: CreateCredentialTemplate; + orgId: string; + issuerId: string; + }): Promise { + const { CredentialTemplate, orgId, issuerId } = payload; + return this.oidcIssuanceService.createTemplate(CredentialTemplate, orgId, issuerId); + } + + @MessagePattern({ cmd: 'oidc-template-update' }) + async oidcTemplateUpdate(payload: { + templateId: string; + dto: UpdateCredentialTemplate; + orgId: string; + issuerId: string; + }): Promise { + const { templateId, dto, orgId, issuerId } = payload; + return this.oidcIssuanceService.updateTemplate(templateId, dto, orgId, issuerId); + } + + @MessagePattern({ cmd: 'oidc-template-delete' }) + async oidcTemplateDelete(payload: { + templateId: string; + orgId: string; + userDetails: user; + issuerId: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }): Promise { + const { templateId, orgId, userDetails, issuerId } = payload; + return this.oidcIssuanceService.deleteTemplate(templateId, orgId, userDetails, issuerId); + } + + @MessagePattern({ cmd: 'oidc-template-find-id' }) + async oidcTemplateFindById(payload: { + templateId: string; + orgId: string; + userDetails: user; + issuerId: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }): Promise { + const { templateId, orgId, userDetails, issuerId } = payload; + return this.oidcIssuanceService.findByIdTemplate(templateId, orgId, userDetails, issuerId); + } + + @MessagePattern({ cmd: 'oidc-template-find-all' }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async oidcTemplateFindAll(payload: { orgId: string; userDetails: user; issuerId: string }): Promise { + const { orgId, userDetails, issuerId } = payload; + return this.oidcIssuanceService.findAllTemplate(orgId, userDetails, issuerId); + } + + @MessagePattern({ cmd: 'oidc-create-credential-offer' }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async createOidcCredentialOffer(payload: { + oidcCredentialPayload: CreateOidcCredentialOffer; + orgId: string; + userDetails: user; + issuerId: string; + }): Promise { + const { oidcCredentialPayload, orgId, userDetails, issuerId } = payload; + return this.oidcIssuanceService.createOidcCredentialOffer(oidcCredentialPayload, orgId, userDetails, issuerId); + } + + //TODO: complete the logic + @MessagePattern({ cmd: 'webhook-oidc-issue-credential' }) + async oidcIssueCredentialWebhook(payload: IssueCredentialWebhookPayload): Promise { + return this.issuanceService.getIssueCredentialWebhook(payload); + } } diff --git a/apps/issuance/src/issuance.module.ts b/apps/issuance/src/issuance.module.ts index 84886a913..080907d74 100644 --- a/apps/issuance/src/issuance.module.ts +++ b/apps/issuance/src/issuance.module.ts @@ -21,6 +21,7 @@ import { ConfigModule as PlatformConfig } from '@credebl/config/config.module'; import { ContextInterceptorModule } from '@credebl/context/contextInterceptorModule'; import { GlobalConfigModule } from '@credebl/config/global-config.module'; import { NATSClient } from '@credebl/common/NATSClient'; +import { OIDCIssuanceService } from './oidc-issuance.service'; @Module({ imports: [ @@ -51,6 +52,7 @@ import { NATSClient } from '@credebl/common/NATSClient'; controllers: [IssuanceController], providers: [ IssuanceService, + OIDCIssuanceService, IssuanceRepository, UserActivityRepository, PrismaService, diff --git a/apps/issuance/src/issuance.repository.ts b/apps/issuance/src/issuance.repository.ts index 8fa95635a..6258a47af 100644 --- a/apps/issuance/src/issuance.repository.ts +++ b/apps/issuance/src/issuance.repository.ts @@ -14,9 +14,11 @@ import { IssueCredentials, IssuedCredentialStatus } from '../enum/issuance.enum' import { Prisma, agent_invitations, + credential_templates, credentials, file_data, file_upload, + oidc_issuer, org_agents, organisation, platform_config, @@ -30,6 +32,7 @@ import { IIssuedCredentialSearchParams } from 'apps/api-gateway/src/issuance/int import { IUserRequest } from '@credebl/user-request/user-request.interface'; import { PrismaService } from '@credebl/prisma-service'; import { ResponseMessages } from '@credebl/common/response-messages'; +import { IssuerMetadata, IssuerUpdation } from '../interfaces/oidc-issuance.interfaces'; @Injectable() export class IssuanceRepository { @@ -793,4 +796,186 @@ export class IssuanceRepository { throw error; } } + + async getOidcIssuerByOrg(orgId: string): Promise { + try { + return await this.prisma.oidc_issuer.findMany({ + where: { createdBy: orgId }, + include: { + templates: true + }, + orderBy: { + createDateTime: 'desc' + } + }); + } catch (error) { + this.logger.error(`Error in getOidcIssuerByOrg: ${error.message}`); + throw error; + } + } + + async getOidcIssuerDetailsById(issuerId: string): Promise { + try { + return await this.prisma.oidc_issuer.findFirstOrThrow({ + where: { id: issuerId } + }); + } catch (error) { + this.logger.error(`Error in getOidcIssuerDetailsById: ${error.message}`); + throw error; + } + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async addOidcIssuerDetails(issuerMetadata: IssuerMetadata, issuerProfileJson): Promise { + try { + const { publicIssuerId, createdById, orgAgentId, batchCredentialIssuanceSize } = issuerMetadata; + const oidcIssuerDetails = await this.prisma.oidc_issuer.create({ + data: { + metadata: issuerProfileJson, + publicIssuerId, + createdBy: createdById, + orgAgentId, + batchCredentialIssuanceSize + } + }); + + return oidcIssuerDetails; + } catch (error) { + this.logger.error(`[addOidcIssuerDetails] - error: ${JSON.stringify(error)}`); + throw error; + } + } + + async updateOidcIssuerDetails(createdById: string, issuerConfig: IssuerUpdation): Promise { + try { + const { issuerId, display, batchCredentialIssuanceSize } = issuerConfig; + const oidcIssuerDetails = await this.prisma.oidc_issuer.update({ + where: { id: issuerId }, + data: { + metadata: display as unknown as Prisma.InputJsonValue, + createdBy: createdById, + ...(batchCredentialIssuanceSize !== undefined ? { batchCredentialIssuanceSize } : {}) + } + }); + + return oidcIssuerDetails; + } catch (error) { + this.logger.error(`[addOidcIssuerDetails] - error: ${JSON.stringify(error)}`); + throw error; + } + } + + async deleteOidcIssuer(issuerId: string): Promise { + try { + return await this.prisma.oidc_issuer.delete({ + where: { id: issuerId } + }); + } catch (error) { + this.logger.error(`[deleteOidcIssuer] - error: ${JSON.stringify(error)}`); + throw error; + } + } + + async createTemplate( + issuerId: string, + data: Omit + ): Promise { + try { + return await this.prisma.credential_templates.create({ + data: { + ...data, + issuerId + } + }); + } catch (error) { + this.logger.error(`Error in createTemplate: ${error.message}`); + throw error; + } + } + + async getTemplateById(templateId: string): Promise { + try { + return await this.prisma.credential_templates.findUnique({ + where: { id: templateId } + }); + } catch (error) { + this.logger.error(`Error in getTemplateById: ${error.message}`); + throw error; + } + } + + async getTemplateByIds(templateIds: string[], issuerId: string): Promise { + try { + // Early return if empty input (avoids full table scan if someone passes []) + if (!Array.isArray(templateIds) || 0 === templateIds.length) { + return []; + } + + this.logger.debug(`getTemplateByIds templateIds=${JSON.stringify(templateIds)} issuerId=${issuerId}`); + + return await this.prisma.credential_templates.findMany({ + where: { + id: { in: templateIds }, + issuerId + } + }); + } catch (error) { + this.logger.error(`Error in getTemplateByIds: ${error?.message}`, error?.stack); + throw error; + } + } + + async getTemplateByNameForIssuer(name: string, issuerId: string): Promise { + try { + return await this.prisma.credential_templates.findMany({ + where: { + issuerId, + name: { + equals: name, + mode: 'insensitive' + } + } + }); + } catch (error) { + this.logger.error(`Error in getTemplateByNameForIssuer: ${error.message}`); + throw error; + } + } + + async getTemplatesByIssuerId(issuerId: string): Promise { + try { + return await this.prisma.credential_templates.findMany({ + where: { issuerId }, + orderBy: { + createdAt: 'desc' + } + }); + } catch (error) { + this.logger.error(`Error in getTemplatesByIssuer: ${error.message}`); + throw error; + } + } + + async updateTemplate(templateId: string, data: Partial): Promise { + try { + return await this.prisma.credential_templates.update({ + where: { id: templateId }, + data + }); + } catch (error) { + this.logger.error(`Error in updateTemplate: ${error.message}`); + throw error; + } + } + + async deleteTemplate(templateId: string): Promise { + try { + return await this.prisma.credential_templates.delete({ + where: { id: templateId } + }); + } catch (error) { + this.logger.error(`Error in deleteTemplate: ${error.message}`); + throw error; + } + } } diff --git a/apps/issuance/src/oidc-issuance.service.ts b/apps/issuance/src/oidc-issuance.service.ts new file mode 100644 index 000000000..da743deb6 --- /dev/null +++ b/apps/issuance/src/oidc-issuance.service.ts @@ -0,0 +1,622 @@ +/* eslint-disable quotes */ +/* eslint-disable no-useless-catch */ +/* eslint-disable camelcase */ +/* eslint-disable no-console */ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/naming-convention, @typescript-eslint/explicit-function-return-type, @typescript-eslint/explicit-module-boundary-types, camelcase */ + +import { + BadRequestException, + ConflictException, + HttpException, + Inject, + Injectable, + InternalServerErrorException, + Logger, + NotFoundException, + Scope +} from '@nestjs/common'; +import { IssuanceRepository } from './issuance.repository'; +import { CommonConstants } from '@credebl/common/common.constant'; +import { ResponseMessages } from '@credebl/common/response-messages'; +import { ClientProxy, RpcException } from '@nestjs/microservices'; +import { map } from 'rxjs'; +import { getAgentUrl } from '@credebl/common/common.utils'; +import { credential_templates, oidc_issuer, user } from '@prisma/client'; +import { + IAgentOIDCIssuerCreate, + IssuerCreation, + IssuerInitialConfig, + IssuerMetadata, + IssuerResponse, + IssuerUpdation +} from '../interfaces/oidc-issuance.interfaces'; +import { CreateCredentialTemplate, UpdateCredentialTemplate } from '../interfaces/oidc-template.interface'; +import { + accessTokenSignerKeyType, + batchCredentialIssuanceDefault, + credentialConfigurationsSupported, + dpopSigningAlgValuesSupported +} from '../constant/issuance'; +import { + buildCredentialConfigurationsSupported, + buildIssuerPayload, + extractTemplateIds +} from '../libs/helpers/issuer.metadata'; +import { + CreateOidcCredentialOffer, + SignerMethodOption, + SignerOption +} from '../interfaces/oidc-issuer-sessions.interfaces'; +import { BadRequestErrorDto } from 'apps/api-gateway/src/dtos/bad-request-error.dto'; +import { buildCredentialOfferPayload, CredentialOfferPayload } from '../libs/helpers/credential-sessions.builder'; + +type CredentialDisplayItem = { + logo?: { uri: string; alt_text?: string }; + name: string; + locale?: string; + description?: string; +}; + +type Appearance = { + display: CredentialDisplayItem[]; +}; +@Injectable() +export class OIDCIssuanceService { + private readonly logger = new Logger('IssueCredentialService'); + constructor( + @Inject('NATS_CLIENT') private readonly issuanceServiceProxy: ClientProxy, + private readonly issuanceRepository: IssuanceRepository + ) {} + + async oidcIssuerCreate(issuerCreation: IssuerCreation, orgId: string, userDetails: user): Promise { + try { + const { issuerId, batchCredentialIssuanceSize } = issuerCreation; + const agentDetails = await this.issuanceRepository.getAgentEndPoint(orgId); + if (!agentDetails) { + throw new NotFoundException(ResponseMessages.issuance.error.agentEndPointNotFound); + } + const { agentEndPoint, id: orgAgentId, orgAgentTypeId } = agentDetails; + const orgAgentType = await this.issuanceRepository.getOrgAgentType(orgAgentTypeId); + if (!orgAgentType) { + throw new NotFoundException(ResponseMessages.issuance.error.orgAgentTypeNotFound); + } + const url = await getAgentUrl(agentEndPoint, CommonConstants.OIDC_ISSUER_CREATE); + const issuerInitialConfig: IssuerInitialConfig = { + issuerId, + display: issuerCreation?.display || {}, + authorizationServerConfigs: issuerCreation?.authorizationServerConfigs || {}, + accessTokenSignerKeyType, + dpopSigningAlgValuesSupported, + batchCredentialIssuance: { + batchSize: batchCredentialIssuanceSize ?? batchCredentialIssuanceDefault + }, + credentialConfigurationsSupported + }; + let createdIssuer; + try { + createdIssuer = await this._createOIDCIssuer(issuerInitialConfig, url, orgId); + } catch (error) { + this.logger.error(`[oidcIssuerCreate] - error in oidcIssuerCreate issuance records: ${JSON.stringify(error)}`); + const status409 = + 409 === error?.status?.message?.statusCode || 409 === error?.response?.status || 409 === error?.statusCode; + + if (status409) { + throw new ConflictException(`Issuer with id '${issuerCreation.issuerId}' already exists`); + } + throw error; + } + const issuerConfigJson = createdIssuer?.response ?? createdIssuer ?? {}; + const issuerIdFromAgent = issuerConfigJson?.issuerId; + + if (!issuerIdFromAgent) { + throw new InternalServerErrorException('Issuer ID missing from agent response'); + } + const issuerMetadata: IssuerMetadata = { + publicIssuerId: issuerIdFromAgent, + createdById: userDetails.id, + orgAgentId, + batchCredentialIssuanceSize: issuerCreation?.batchCredentialIssuanceSize + }; + const addOidcIssuerDetails = await this.issuanceRepository.addOidcIssuerDetails( + issuerMetadata, + issuerCreation?.display + ); + + if (!addOidcIssuerDetails) { + throw new InternalServerErrorException('Error in adding OIDC Issuer details in DB'); + } + return addOidcIssuerDetails; + } catch (error) { + this.logger.error(`[oidcIssuerCreate] - error in oidcIssuerCreate issuance records: ${JSON.stringify(error)}`); + throw new RpcException(error?.response ?? error); + } + } + + async oidcIssuerUpdate(issuerUpdationConfig: IssuerUpdation, orgId: string, userDetails: user): Promise { + try { + const getIssuerDetails = await this.issuanceRepository.getOidcIssuerDetailsById(issuerUpdationConfig.issuerId); + if (!getIssuerDetails) { + throw new NotFoundException(ResponseMessages.oidcIssuer.error.notFound); + } + const agentDetails = await this.issuanceRepository.getAgentEndPoint(orgId); + if (!agentDetails) { + throw new NotFoundException(ResponseMessages.issuance.error.agentEndPointNotFound); + } + const { agentEndPoint, id: orgAgentId, orgAgentTypeId } = agentDetails; + const orgAgentType = await this.issuanceRepository.getOrgAgentType(orgAgentTypeId); + if (!orgAgentType) { + throw new NotFoundException(ResponseMessages.issuance.error.orgAgentTypeNotFound); + } + + const addOidcIssuerDetails = await this.issuanceRepository.updateOidcIssuerDetails( + userDetails.id, + issuerUpdationConfig + ); + + if (!addOidcIssuerDetails) { + throw new InternalServerErrorException('Error in updating OIDC Issuer details in DB'); + } + + const url = await getAgentUrl( + agentEndPoint, + CommonConstants.OIDC_ISSUER_TEMPLATE, + getIssuerDetails.publicIssuerId + ); + const issuerConfig = await this.buildOidcIssuerConfig(issuerUpdationConfig.issuerId); + const updatedIssuer = await this._createOIDCTemplate(issuerConfig, url, orgId); + if (updatedIssuer?.response?.statusCode && 200 !== updatedIssuer?.response?.statusCode) { + throw new InternalServerErrorException( + `Error from agent while updating issuer: ${updatedIssuer?.response?.message ?? 'Unknown error'}` + ); + } + + return addOidcIssuerDetails; + } catch (error) { + this.logger.error(`[oidcIssuerUpdate] - error in oidcIssuerUpdate issuance records: ${JSON.stringify(error)}`); + throw new RpcException(error?.response ?? error); + } + } + + async oidcIssuerGetById(issuerId: string, orgId: string): Promise { + try { + const getIssuerDetails = await this.issuanceRepository.getOidcIssuerDetailsById(issuerId); + if (!getIssuerDetails && getIssuerDetails.publicIssuerId) { + throw new NotFoundException(ResponseMessages.oidcIssuer.error.notFound); + } + const agentDetails = await this.issuanceRepository.getAgentEndPoint(orgId); + if (!agentDetails) { + throw new NotFoundException(ResponseMessages.issuance.error.agentEndPointNotFound); + } + const url = await getAgentUrl( + agentDetails?.agentEndPoint, + CommonConstants.OIDC_ISSUER_BY_ID, + getIssuerDetails?.publicIssuerId + ); + const issuerDetailsRaw = await this._oidcGetIssuerById(url, orgId); + + if (!issuerDetailsRaw) { + throw new InternalServerErrorException(`Error from agent while getting issuer`); + } + + // response is a string → parse it into IssuerResponse + const issuerDetails = { + response: JSON.parse(issuerDetailsRaw.response) as IssuerResponse + }; + + return issuerDetails.response; + } catch (error) { + this.logger.error(`[oidcIssuerGetById] - error in oidcIssuerGetById issuance records: ${JSON.stringify(error)}`); + throw new RpcException(error?.response ?? error); + } + } + + async oidcIssuers(orgId: string): Promise { + try { + const agentDetails = await this.issuanceRepository.getAgentEndPoint(orgId); + if (!agentDetails) { + throw new NotFoundException(ResponseMessages.issuance.error.agentEndPointNotFound); + } + const url = await getAgentUrl(agentDetails?.agentEndPoint, CommonConstants.OIDC_GET_ALL_ISSUERS); + const issuersDetails = await this._oidcGetIssuers(url, orgId); + if (!issuersDetails) { + throw new InternalServerErrorException(`Error from agent while oidcIssuers`); + } + + return issuersDetails; + } catch (error) { + this.logger.error(`[oidcIssuers] - error in oidcIssuers issuance records: ${JSON.stringify(error)}`); + throw new RpcException(error?.response ?? error); + } + } + + async deleteOidcIssuer(orgId: string, userDetails: user, issuerId: string) { + try { + const deleteOidcIssuer = await this.issuanceRepository.deleteOidcIssuer(issuerId); + if (!deleteOidcIssuer) { + throw new NotFoundException(ResponseMessages.oidcTemplate.error.deleteTemplate); + } + + const issuerRecordId = await this.oidcIssuerGetById(issuerId, orgId); + if (!issuerRecordId.id) { + throw new NotFoundException(ResponseMessages.oidcIssuer.error.notFound); + } + const agentDetails = await this.issuanceRepository.getAgentEndPoint(orgId); + if (!agentDetails) { + throw new NotFoundException(ResponseMessages.issuance.error.agentEndPointNotFound); + } + const { agentEndPoint } = agentDetails; + const issuerDetails = await this.issuanceRepository.getOidcIssuerDetailsById(issuerId); + if (!issuerDetails) { + throw new NotFoundException(ResponseMessages.oidcTemplate.error.issuerDetailsNotFound); + } + const url = await getAgentUrl(agentEndPoint, CommonConstants.OIDC_ISSUER_DELETE, issuerRecordId.id); + + const createTemplateOnAgent = await this._deleteOidcIssuer(url, orgId); + if (!createTemplateOnAgent) { + throw new NotFoundException(ResponseMessages.issuance.error.agentEndPointNotFound); + } + return deleteOidcIssuer; + } catch (error) { + this.logger.error(`[deleteTemplate] - error: ${JSON.stringify(error)}`); + throw new RpcException(error.response ?? error); + } + } + + async createTemplate( + CredentialTemplate: CreateCredentialTemplate, + orgId: string, + issuerId: string + ): Promise { + try { + const { name, description, format, canBeRevoked, attributes, appearance, signerOption } = CredentialTemplate; + const checkNameExist = await this.issuanceRepository.getTemplateByNameForIssuer(name, issuerId); + if (0 < checkNameExist.length) { + throw new ConflictException(ResponseMessages.oidcTemplate.error.templateNameAlreadyExist); + } + const metadata = { + name, + description, + format, + canBeRevoked, + attributes, + appearance: appearance ?? {}, + issuerId, + signerOption + }; + // Persist in DB + const createdTemplate = await this.issuanceRepository.createTemplate(issuerId, metadata); + if (!createdTemplate) { + throw new InternalServerErrorException(ResponseMessages.oidcTemplate.error.createFailed); + } + const issuerTemplateConfig = await this.buildOidcIssuerConfig(issuerId); + const agentDetails = await this.issuanceRepository.getAgentEndPoint(orgId); + if (!agentDetails) { + throw new NotFoundException(ResponseMessages.issuance.error.agentEndPointNotFound); + } + const { agentEndPoint } = agentDetails; + const issuerDetails = await this.issuanceRepository.getOidcIssuerDetailsById(issuerId); + if (!issuerDetails) { + throw new NotFoundException(ResponseMessages.oidcTemplate.error.issuerDetailsNotFound); + } + const url = await getAgentUrl(agentEndPoint, CommonConstants.OIDC_ISSUER_TEMPLATE, issuerDetails.publicIssuerId); + const createTemplateOnAgent = await this._createOIDCTemplate(issuerTemplateConfig, url, orgId); + if (!createTemplateOnAgent) { + throw new NotFoundException(ResponseMessages.issuance.error.agentEndPointNotFound); + } + return createdTemplate; + } catch (error) { + this.logger.error(`[createTemplate] - error: ${JSON.stringify(error)}`); + throw new RpcException(error.response ?? error); + } + } + + async updateTemplate( + templateId: string, + updateCredentialTemplate: UpdateCredentialTemplate, + orgId: string, + issuerId: string + ): Promise { + try { + const template = await this.issuanceRepository.getTemplateById(templateId); + if (!template) { + throw new NotFoundException(ResponseMessages.oidcTemplate.error.notFound); + } + if (updateCredentialTemplate.name) { + const checkNameExist = await this.issuanceRepository.getTemplateByNameForIssuer( + updateCredentialTemplate.name, + issuerId + ); + if (0 < checkNameExist.length) { + throw new ConflictException(ResponseMessages.oidcTemplate.error.templateNameAlreadyExist); + } + } + const normalized = { + ...updateCredentialTemplate, + ...(issuerId ? { issuerId } : {}) + }; + const { name, description, format, canBeRevoked, attributes, appearance } = normalized; + + const payload = { + ...(name !== undefined ? { name } : {}), + ...(description !== undefined ? { description } : {}), + ...(format !== undefined ? { format } : {}), + ...(canBeRevoked !== undefined ? { canBeRevoked } : {}), + ...(attributes !== undefined ? { attributes } : {}), + ...(appearance !== undefined ? { appearance } : {}), + ...(issuerId ? { issuerId } : {}) + }; + + const updatedTemplate = await this.issuanceRepository.updateTemplate(templateId, payload); + + const templates = await this.issuanceRepository.getTemplatesByIssuerId(issuerId); + if (!templates || 0 === templates.length) { + throw new NotFoundException(ResponseMessages.issuance.error.notFound); + } + const issuerTemplateConfig = await this.buildOidcIssuerConfig(issuerId); + const agentDetails = await this.issuanceRepository.getAgentEndPoint(orgId); + if (!agentDetails) { + throw new NotFoundException(ResponseMessages.issuance.error.agentEndPointNotFound); + } + const { agentEndPoint } = agentDetails; + const issuerDetails = await this.issuanceRepository.getOidcIssuerDetailsById(issuerId); + if (!issuerDetails) { + throw new NotFoundException(ResponseMessages.oidcTemplate.error.issuerDetailsNotFound); + } + const url = await getAgentUrl(agentEndPoint, CommonConstants.OIDC_ISSUER_TEMPLATE, issuerDetails.publicIssuerId); + + const createTemplateOnAgent = await this._createOIDCTemplate(issuerTemplateConfig, url, orgId); + if (!createTemplateOnAgent) { + throw new NotFoundException(ResponseMessages.issuance.error.agentEndPointNotFound); + } + + return updatedTemplate; + } catch (error) { + this.logger.error(`[updateTemplate] - error: ${JSON.stringify(error)}`); + throw new RpcException(error.response ?? error); + } + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async deleteTemplate(templateId: string, orgId: string, userDetails: user, issuerId: string): Promise { + try { + const template = await this.issuanceRepository.getTemplateById(templateId); + if (!template) { + throw new NotFoundException(ResponseMessages.oidcTemplate.error.notFound); + } + const deleteTemplate = await this.issuanceRepository.deleteTemplate(templateId); + if (!deleteTemplate) { + throw new NotFoundException(ResponseMessages.oidcTemplate.error.deleteTemplate); + } + + const issuerTemplateConfig = await this.buildOidcIssuerConfig(issuerId); + const agentDetails = await this.issuanceRepository.getAgentEndPoint(orgId); + if (!agentDetails) { + throw new NotFoundException(ResponseMessages.issuance.error.agentEndPointNotFound); + } + const { agentEndPoint } = agentDetails; + const issuerDetails = await this.issuanceRepository.getOidcIssuerDetailsById(issuerId); + if (!issuerDetails) { + throw new NotFoundException(ResponseMessages.oidcTemplate.error.issuerDetailsNotFound); + } + const url = await getAgentUrl(agentEndPoint, CommonConstants.OIDC_ISSUER_TEMPLATE, issuerDetails.publicIssuerId); + + const createTemplateOnAgent = await this._createOIDCTemplate(issuerTemplateConfig, url, orgId); + if (!createTemplateOnAgent) { + throw new NotFoundException(ResponseMessages.issuance.error.agentEndPointNotFound); + } + return deleteTemplate; + } catch (error) { + this.logger.error(`[deleteTemplate] - error: ${JSON.stringify(error)}`); + throw new RpcException(error.response ?? error); + } + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async findByIdTemplate(templateId: string, orgId: string, userDetails: user, issuerId: string): Promise { + try { + const template = await this.issuanceRepository.getTemplateById(templateId); + if (!template) { + throw new NotFoundException(ResponseMessages.oidcTemplate.error.notFound); + } + + return { message: ResponseMessages.oidcTemplate.success.fetch, data: template }; + } catch (error) { + this.logger.error(`[findByIdTemplate] - error: ${JSON.stringify(error)}`); + throw new RpcException(error.response ?? error); + } + } + + async createOidcCredentialOffer( + createOidcCredentialOffer: CreateOidcCredentialOffer, + orgId: string, + userDetails: user, + issuerId: string + ): Promise { + try { + const filterTemplateIds = extractTemplateIds(createOidcCredentialOffer); + if (!filterTemplateIds) { + throw new BadRequestException('Please provide a valid id'); + } + const getAllOfferTemplates = await this.issuanceRepository.getTemplateByIds(filterTemplateIds, issuerId); + if (!getAllOfferTemplates) { + throw new NotFoundException('No templates found for the issuer'); + } + + const agentDetails = await this.issuanceRepository.getAgentEndPoint(orgId); + //TDOD: signerOption should be under credentials change this with x509 support + const signerOption: SignerOption = { + method: SignerMethodOption.DID, + did: agentDetails.orgDid + }; + const buildOidcCredentialOffer: CredentialOfferPayload = buildCredentialOfferPayload( + createOidcCredentialOffer, + getAllOfferTemplates, + signerOption + ); + if (!buildOidcCredentialOffer) { + throw new BadRequestException('Error while creating oidc credential offer'); + } + const issuerDetails = await this.issuanceRepository.getOidcIssuerDetailsById(issuerId); + if (!issuerDetails) { + throw new NotFoundException(ResponseMessages.oidcTemplate.error.issuerDetailsNotFound); + } + buildOidcCredentialOffer.publicIssuerId = issuerDetails.publicIssuerId; + const url = await getAgentUrl( + await this.getAgentEndpoint(orgId), + CommonConstants.OIDC_ISSUER_SESSIONS_CREDENTIAL_OFFER, + issuerDetails.publicIssuerId + ); + + const createCredentialOfferOnAgent = await this._oidcCreateCredentialOffer(buildOidcCredentialOffer, url, orgId); + if (!createCredentialOfferOnAgent) { + throw new NotFoundException(ResponseMessages.oidcIssuerSession.error.errroCreateOffer); + } + + return createCredentialOfferOnAgent.response; + } catch (error) { + this.logger.error(`[createOidcCredentialOffer] - error: ${JSON.stringify(error)}`); + throw new RpcException(error.response ?? error); + } + } + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + async buildOidcIssuerConfig(issuerId: string) { + try { + const issuerDetails = await this.issuanceRepository.getOidcIssuerDetailsById(issuerId); + const templates = await this.issuanceRepository.getTemplatesByIssuerId(issuerId); + + const credentialConfigurationsSupported = buildCredentialConfigurationsSupported(templates); + + return buildIssuerPayload(credentialConfigurationsSupported, issuerDetails); + } catch (error) { + this.logger.error(`[buildOidcIssuerPayload] - error: ${JSON.stringify(error)}`); + throw new RpcException(error.response ?? error); + } + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async findAllTemplate(orgId: string, userDetails: user, issuerId: string): Promise { + try { + const templates = await this.issuanceRepository.getTemplatesByIssuerId(issuerId); + return { message: ResponseMessages.oidcTemplate.success.fetch, data: templates }; + } catch (error) { + this.logger.error(`[findAllTemplate] - error: ${JSON.stringify(error)}`); + throw new RpcException(error.response ?? error); + } + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async _createOIDCIssuer(issuerCreation, url: string, orgId: string): Promise { + try { + const pattern = { cmd: 'agent-create-oidc-issuer' }; + const payload: IAgentOIDCIssuerCreate = { issuerCreation, url, orgId }; + return this.natsCall(pattern, payload); + } catch (error) { + this.logger.error(`[_createOIDCIssuer] [NATS call]- error in create OIDC Issuer : ${JSON.stringify(error)}`); + throw error; + } + } + + async _createOIDCTemplate(templatePayload, url: string, orgId: string): Promise { + try { + const pattern = { cmd: 'agent-create-oidc-template' }; + const payload = { templatePayload, url, orgId }; + return this.natsCall(pattern, payload); + } catch (error) { + this.logger.error(`[_createOIDCTemplate] [NATS call]- error in create OIDC Template : ${JSON.stringify(error)}`); + throw error; + } + } + + async _deleteOidcIssuer(url: string, orgId: string): Promise { + try { + const pattern = { cmd: 'delete-oidc-issuer' }; + const payload = { url, orgId }; + return this.natsCall(pattern, payload); + } catch (error) { + this.logger.error(`[_createOIDCTemplate] [NATS call]- error in create OIDC Template : ${JSON.stringify(error)}`); + throw error; + } + } + + async _oidcGetIssuerById(url: string, orgId: string) { + try { + const pattern = { cmd: 'oidc-get-issuer-by-id' }; + const payload = { url, orgId }; + return this.natsCall(pattern, payload); + } catch (error) { + this.logger.error(`[_oidcGetIssuerById] [NATS call]- error in oidc get issuer by id : ${JSON.stringify(error)}`); + throw error; + } + } + + async _oidcGetIssuers(url: string, orgId: string) { + try { + const pattern = { cmd: 'oidc-get-issuers' }; + const payload = { url, orgId }; + return this.natsCall(pattern, payload); + } catch (error) { + this.logger.error(`[_oidcGetIssuers] [NATS call]- error in oidc get issuers : ${JSON.stringify(error)}`); + throw error; + } + } + + async _oidcCreateCredentialOffer(credentialPayload: CredentialOfferPayload, url: string, orgId: string) { + try { + const pattern = { cmd: 'agent-service-oidc-create-credential-offer' }; + const payload = { credentialPayload, url, orgId }; + return this.natsCall(pattern, payload); + } catch (error) { + this.logger.error( + `[_oidcCreateCredentialOffer] [NATS call]- error in oidc create credential offer : ${JSON.stringify(error)}` + ); + throw error; + } + } + + async natsCall( + pattern: object, + payload: object + ): Promise<{ + response: string; + }> { + try { + return this.issuanceServiceProxy + .send(pattern, payload) + .pipe( + map((response) => ({ + response + })) + ) + .toPromise() + .catch((error) => { + this.logger.error(`catch: ${JSON.stringify(error)}`); + throw new HttpException( + { + status: error.statusCode, + error: error.message + }, + error.error + ); + }); + } catch (error) { + this.logger.error(`[natsCall] - error in nats call : ${JSON.stringify(error)}`); + throw error; + } + } + + async getAgentEndpoint(orgId: string): Promise { + const agentDetails = await this.issuanceRepository.getAgentEndPoint(orgId); + + if (!agentDetails) { + throw new NotFoundException(ResponseMessages.issuance.error.agentEndPointNotFound); + } + + if (!agentDetails.agentEndPoint || '' === agentDetails.agentEndPoint.trim()) { + throw new NotFoundException(ResponseMessages.issuance.error.agentEndPointNotFound); + } + + return agentDetails.agentEndPoint; + } +} diff --git a/libs/common/src/common.constant.ts b/libs/common/src/common.constant.ts index 02c6efd05..da939394d 100644 --- a/libs/common/src/common.constant.ts +++ b/libs/common/src/common.constant.ts @@ -117,6 +117,13 @@ export enum CommonConstants { // CREATE KEYS CREATE_POLYGON_SECP256k1_KEY = '/polygon/create-keys', + // OIDC URLs + URL_OIDC_ISSUER_CREATE = '/openid4vc/issuer', + /* eslint-disable @typescript-eslint/naming-convention, @typescript-eslint/explicit-function-return-type, @typescript-eslint/explicit-module-boundary-types, camelcase, @typescript-eslint/no-duplicate-enum-values */ + URL_OIDC_GET_ISSUES = '/openid4vc/issuer', + URL_OIDC_ISSUER_UPDATE = '/openid4vc/issuer/#', + URL_OIDC_ISSUER_SESSIONS_CREATE = '/openid4vc/issuance-sessions/create-credential-offer', + // Nested attribute separator NESTED_ATTRIBUTE_SEPARATOR = '~', @@ -383,7 +390,15 @@ export enum CommonConstants { GET_VERIFIED_PROOF = 'get-verified-proof', GET_QUESTION_ANSWER_RECORD = 'get-question-answer-record', SEND_QUESTION = 'send-question', - SEND_BASIC_MESSAGE = 'send-basic-message' + SEND_BASIC_MESSAGE = 'send-basic-message', + + // OIDC + OIDC_ISSUER_CREATE = 'create-oidc-issuer', + OIDC_ISSUER_DELETE = 'delete-oidc-issuer', + OIDC_GET_ALL_ISSUERS = 'get-all-oidc-issuers', + OIDC_ISSUER_BY_ID = 'get-issuer-by-id', + OIDC_ISSUER_TEMPLATE = 'create-oidc-template', + OIDC_ISSUER_SESSIONS_CREDENTIAL_OFFER = 'create-oidc-credential-offer' } export const MICRO_SERVICE_NAME = Symbol('MICRO_SERVICE_NAME'); export const ATTRIBUTE_NAME_REGEX = /\['(.*?)'\]/; diff --git a/libs/common/src/common.utils.ts b/libs/common/src/common.utils.ts index 938e795c3..b1c70e012 100644 --- a/libs/common/src/common.utils.ts +++ b/libs/common/src/common.utils.ts @@ -93,7 +93,16 @@ export const getAgentUrl = async (agentEndPoint: string, urlFlag: string, paramI [String(CommonConstants.GET_VERIFIED_PROOF), String(CommonConstants.URL_PROOF_FORM_DATA)], [String(CommonConstants.GET_QUESTION_ANSWER_RECORD), String(CommonConstants.URL_QUESTION_ANSWER_RECORD)], [String(CommonConstants.SEND_QUESTION), String(CommonConstants.URL_SEND_QUESTION)], - [String(CommonConstants.SEND_BASIC_MESSAGE), String(CommonConstants.URL_SEND_BASIC_MESSAGE)] + [String(CommonConstants.SEND_BASIC_MESSAGE), String(CommonConstants.URL_SEND_BASIC_MESSAGE)], + [String(CommonConstants.OIDC_ISSUER_CREATE), String(CommonConstants.URL_OIDC_ISSUER_CREATE)], + [String(CommonConstants.OIDC_GET_ALL_ISSUERS), String(CommonConstants.URL_OIDC_GET_ISSUES)], + [String(CommonConstants.OIDC_ISSUER_DELETE), String(CommonConstants.URL_OIDC_ISSUER_UPDATE)], + [String(CommonConstants.OIDC_ISSUER_BY_ID), String(CommonConstants.URL_OIDC_ISSUER_UPDATE)], + [String(CommonConstants.OIDC_ISSUER_TEMPLATE), String(CommonConstants.URL_OIDC_ISSUER_UPDATE)], + [ + String(CommonConstants.OIDC_ISSUER_SESSIONS_CREDENTIAL_OFFER), + String(CommonConstants.URL_OIDC_ISSUER_SESSIONS_CREATE) + ] ]); const urlSuffix = agentUrlMap.get(urlFlag); diff --git a/libs/common/src/response-messages/index.ts b/libs/common/src/response-messages/index.ts index 6174734e8..681751427 100644 --- a/libs/common/src/response-messages/index.ts +++ b/libs/common/src/response-messages/index.ts @@ -497,6 +497,54 @@ export const ResponseMessages = { walletRecordNotFound: 'Wallet record not found.' } }, + oidcIssuer: { + success: { + create: 'OIDC issuer created successfully.', + update: 'OIDC issuer updated successfully.', + delete: 'OIDC issuer deleted successfully.', + fetch: 'OIDC issuer(s) fetched successfully.', + issuerConfig: 'Issuer config details created successfully', + issuerConfigUpdate: 'Issuer config details updated successfully' + }, + error: { + notFound: 'OIDC issuer not found.', + invalidId: 'Invalid OIDC issuer ID.', + createFailed: 'Failed to create OIDC issuer.', + updateFailed: 'Failed to update OIDC issuer.', + deleteFailed: 'Failed to delete OIDC issuer.' + } + }, + oidcTemplate: { + success: { + create: 'OIDC template created successfully.', + update: 'OIDC template updated successfully.', + delete: 'OIDC template deleted successfully.', + fetch: 'OIDC template(s) fetched successfully.' + }, + error: { + notFound: 'OIDC template not found.', + invalidId: 'Invalid OIDC template ID.', + createFailed: 'Failed to create OIDC template.', + updateFailed: 'Failed to update OIDC template.', + deleteFailed: 'Failed to delete OIDC template.', + issuerDisplayNotFound: 'Issuer display not found.', + issuerDetailsNotFound: 'Issuer details not found.', + templateNameAlreadyExist: 'Template name already exists for this issuer.', + deleteTemplate: 'Error while deleting template.' + } + }, + oidcIssuerSession: { + success: { + create: 'OIDC Credential offer created successfully.' + }, + error: { + errroCreateOffer: 'Error while creating OIDC credential offer on agent.', + invalidId: 'Invalid OIDC issuer ID.', + createFailed: 'Failed to create OIDC issuer.', + updateFailed: 'Failed to update OIDC issuer.', + deleteFailed: 'Failed to delete OIDC issuer.' + } + }, nats: { success: {}, error: { diff --git a/libs/prisma-service/prisma/migrations/20250814141522_add_supported_protocol/migration.sql b/libs/prisma-service/prisma/migrations/20250814141522_add_supported_protocol/migration.sql new file mode 100644 index 000000000..1c42976ca --- /dev/null +++ b/libs/prisma-service/prisma/migrations/20250814141522_add_supported_protocol/migration.sql @@ -0,0 +1,94 @@ +-- Create enum type +CREATE TYPE "CredentialExchangeProtocol" AS ENUM ('OIDC', 'DIDCOMM'); + +-- Add column +ALTER TABLE "organisation" +ADD COLUMN "supported_protocol" "CredentialExchangeProtocol"[]; + +-- Set default for new rows +ALTER TABLE "organisation" +ALTER COLUMN "supported_protocol" +SET DEFAULT ARRAY['DIDCOMM']::"CredentialExchangeProtocol"[]; + +-- Clean existing duplicates before constraint +-- UPDATE "organisation" +-- SET "supported_protocol" = ARRAY( +-- SELECT DISTINCT unnest("supported_protocol") +-- ) +-- WHERE "supported_protocol" IS NOT NULL; + +-- Backfill missing/empty with DIDCOMM +UPDATE "organisation" +SET "supported_protocol" = ARRAY['DIDCOMM']::"CredentialExchangeProtocol"[] +WHERE "supported_protocol" IS NULL OR cardinality("supported_protocol") = 0; + +-- Add no-duplicates constraint +-- ALTER TABLE "organisation" +-- ADD CONSTRAINT supported_protocol_unique +-- CHECK ( +-- cardinality(supported_protocol) = cardinality( +-- ARRAY(SELECT DISTINCT unnest(supported_protocol)) +-- ) +-- ); +-- trigger function to check unique values +CREATE OR REPLACE FUNCTION check_unique_protocols() +RETURNS trigger AS $$ +BEGIN + IF (SELECT COUNT(*) FROM unnest(NEW.supported_protocol) v + GROUP BY v HAVING COUNT(*) > 1 LIMIT 1) IS NOT NULL THEN + RAISE EXCEPTION 'supported_protocol contains duplicates'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER enforce_unique_protocols +BEFORE INSERT OR UPDATE ON organisation +FOR EACH ROW EXECUTE FUNCTION check_unique_protocols(); + + + +-- contraints, triggers and migrations for ledger table +ALTER TABLE "ledgers" +ADD COLUMN "supported_protocol" "CredentialExchangeProtocol"[]; + +-- Set default for new rows +ALTER TABLE "ledgers" +ALTER COLUMN "supported_protocol" +SET DEFAULT ARRAY['DIDCOMM']::"CredentialExchangeProtocol"[]; + +-- Clean existing duplicates before constraint +-- UPDATE "ledgers" +-- SET "supported_protocol" = ARRAY( +-- SELECT DISTINCT unnest("supported_protocol") +-- ) +-- WHERE "supported_protocol" IS NOT NULL; + +-- Backfill missing/empty with DIDCOMM +UPDATE "ledgers" +SET "supported_protocol" = ARRAY['DIDCOMM']::"CredentialExchangeProtocol"[] +WHERE "supported_protocol" IS NULL OR cardinality("supported_protocol") = 0; + +-- Add no-duplicates constraint +-- ALTER TABLE "ledgers" +-- ADD CONSTRAINT supported_protocol_unique +-- CHECK ( +-- cardinality(supported_protocol) = cardinality( +-- ARRAY(SELECT DISTINCT unnest(supported_protocol)) +-- ) +-- ); +-- trigger function to check unique values +CREATE OR REPLACE FUNCTION check_unique_protocols() +RETURNS trigger AS $$ +BEGIN + IF (SELECT COUNT(*) FROM unnest(NEW.supported_protocol) v + GROUP BY v HAVING COUNT(*) > 1 LIMIT 1) IS NOT NULL THEN + RAISE EXCEPTION 'supported_protocol contains duplicates'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER enforce_unique_protocols +BEFORE INSERT OR UPDATE ON ledgers +FOR EACH ROW EXECUTE FUNCTION check_unique_protocols(); \ No newline at end of file diff --git a/libs/prisma-service/prisma/migrations/20250822104325_oidc_issuer_templates/migration.sql b/libs/prisma-service/prisma/migrations/20250822104325_oidc_issuer_templates/migration.sql new file mode 100644 index 000000000..4118c6b20 --- /dev/null +++ b/libs/prisma-service/prisma/migrations/20250822104325_oidc_issuer_templates/migration.sql @@ -0,0 +1,17 @@ +-- Drop the table if it exists (safe for dev) +DROP TABLE IF EXISTS "public"."credential_templates"; + +-- Create table +CREATE TABLE "public"."credential_templates" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "format" TEXT NOT NULL, + "issuer" TEXT NOT NULL, + "canBeRevoked" BOOLEAN NOT NULL DEFAULT false, + "attributes" JSON NOT NULL, + "appearance" JSON NOT NULL, + "createdAt" TIMESTAMPTZ(6) NOT NULL DEFAULT now(), + "updatedAt" TIMESTAMPTZ(6) NOT NULL DEFAULT now(), + CONSTRAINT "credential_templates_pkey" PRIMARY KEY ("id") +); diff --git a/libs/prisma-service/prisma/migrations/20250822145801_fix_issuer_relation/migration.sql b/libs/prisma-service/prisma/migrations/20250822145801_fix_issuer_relation/migration.sql new file mode 100644 index 000000000..643f2dbf2 --- /dev/null +++ b/libs/prisma-service/prisma/migrations/20250822145801_fix_issuer_relation/migration.sql @@ -0,0 +1,32 @@ +/* + Warnings: + + - You are about to drop the column `issuer` on the `credential_templates` table. All the data in the column will be lost. + - Added the required column `issuerId` to the `credential_templates` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "credential_templates" DROP COLUMN "issuer", +ADD COLUMN "issuerId" UUID NOT NULL, +ALTER COLUMN "attributes" SET DATA TYPE JSONB, +ALTER COLUMN "appearance" SET DATA TYPE JSONB, +ALTER COLUMN "createdAt" SET DATA TYPE TIMESTAMP(3), +ALTER COLUMN "updatedAt" DROP DEFAULT, +ALTER COLUMN "updatedAt" SET DATA TYPE TIMESTAMP(3); + +-- CreateTable +CREATE TABLE "oidc_issuer" ( + "id" UUID NOT NULL, + "createDateTime" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "createdBy" UUID NOT NULL, + "agentIssuerId" TEXT NOT NULL, + "metadata" JSONB NOT NULL, + + CONSTRAINT "oidc_issuer_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "oidc_issuer" ADD CONSTRAINT "oidc_issuer_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "org_agents"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "credential_templates" ADD CONSTRAINT "credential_templates_issuerId_fkey" FOREIGN KEY ("issuerId") REFERENCES "oidc_issuer"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/libs/prisma-service/prisma/migrations/20250902082321_add_org_agent_id_to_oidc_issuer/migration.sql b/libs/prisma-service/prisma/migrations/20250902082321_add_org_agent_id_to_oidc_issuer/migration.sql new file mode 100644 index 000000000..b39a42c37 --- /dev/null +++ b/libs/prisma-service/prisma/migrations/20250902082321_add_org_agent_id_to_oidc_issuer/migration.sql @@ -0,0 +1,21 @@ +/* + Warnings: + + - You are about to drop the column `agentIssuerId` on the `oidc_issuer` table. All the data in the column will be lost. + - Added the required column `orgAgentId` to the `oidc_issuer` table without a default value. This is not possible if the table is not empty. + - Added the required column `publicIssuerId` to the `oidc_issuer` table without a default value. This is not possible if the table is not empty. + +*/ +-- DropForeignKey +ALTER TABLE "oidc_issuer" DROP CONSTRAINT "oidc_issuer_createdBy_fkey"; + +-- AlterTable +ALTER TABLE "oidc_issuer" DROP COLUMN "agentIssuerId", +ADD COLUMN "orgAgentId" UUID NOT NULL, +ADD COLUMN "publicIssuerId" TEXT NOT NULL; + +-- CreateIndex +CREATE INDEX "oidc_issuer_orgAgentId_idx" ON "oidc_issuer"("orgAgentId"); + +-- AddForeignKey +ALTER TABLE "oidc_issuer" ADD CONSTRAINT "oidc_issuer_orgAgentId_fkey" FOREIGN KEY ("orgAgentId") REFERENCES "org_agents"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/libs/prisma-service/prisma/migrations/20250904181430_added_fields_in_oidc_issuer/migration.sql b/libs/prisma-service/prisma/migrations/20250904181430_added_fields_in_oidc_issuer/migration.sql new file mode 100644 index 000000000..f8517c3c7 --- /dev/null +++ b/libs/prisma-service/prisma/migrations/20250904181430_added_fields_in_oidc_issuer/migration.sql @@ -0,0 +1,6 @@ +-- CreateEnum +CREATE TYPE "AccessTokenSignerKeyType" AS ENUM ('ed25519'); + +-- AlterTable +ALTER TABLE "oidc_issuer" ADD COLUMN "accessTokenSignerKeyType" "AccessTokenSignerKeyType"[] DEFAULT ARRAY['ed25519']::"AccessTokenSignerKeyType"[], +ADD COLUMN "batchCredentialIssuanceSize" INTEGER NOT NULL DEFAULT 0; diff --git a/libs/prisma-service/prisma/migrations/20250911102632_added_field_signer_option_in_credential_templates/migration.sql b/libs/prisma-service/prisma/migrations/20250911102632_added_field_signer_option_in_credential_templates/migration.sql new file mode 100644 index 000000000..58d8d3ebf --- /dev/null +++ b/libs/prisma-service/prisma/migrations/20250911102632_added_field_signer_option_in_credential_templates/migration.sql @@ -0,0 +1,18 @@ +/* + Warnings: + + - You are about to drop the column `accessTokenSignerKeyType` on the `oidc_issuer` table. All the data in the column will be lost. + - Added the required column `signerOption` to the `credential_templates` table without a default value. This is not possible if the table is not empty. + +*/ +-- CreateEnum +CREATE TYPE "SignerOption" AS ENUM ('did', 'x509'); + +-- AlterTable +ALTER TABLE "credential_templates" ADD COLUMN "signerOption" "SignerOption" NOT NULL; + +-- AlterTable +ALTER TABLE "oidc_issuer" DROP COLUMN "accessTokenSignerKeyType"; + +-- DropEnum +DROP TYPE "AccessTokenSignerKeyType"; diff --git a/libs/prisma-service/prisma/schema.prisma b/libs/prisma-service/prisma/schema.prisma index 337c953a2..e544c8e94 100644 --- a/libs/prisma-service/prisma/schema.prisma +++ b/libs/prisma-service/prisma/schema.prisma @@ -227,6 +227,7 @@ model org_agents { organisation organisation? @relation(fields: [orgId], references: [id]) webhookUrl String? @db.VarChar org_dids org_dids[] + oidc_issuer oidc_issuer[] } model org_dids { @@ -566,3 +567,39 @@ model client_aliases { clientAlias String? clientUrl String } + +model oidc_issuer { + id String @id @default(uuid()) @db.Uuid + createDateTime DateTime @default(now()) @db.Timestamptz(6) + createdBy String @db.Uuid + publicIssuerId String + metadata Json + orgAgentId String @db.Uuid + orgAgent org_agents @relation(fields: [orgAgentId], references: [id]) + templates credential_templates[] + batchCredentialIssuanceSize Int @default(0) + @@index([orgAgentId]) +} + +enum SignerOption { + did + x509 +} + +model credential_templates { + id String @id @default(uuid()) + name String + description String? + format String // e.g. "sd_jwt", "mso_mdoc" + canBeRevoked Boolean @default(false) + + attributes Json + appearance Json + + issuerId String @db.Uuid + issuer oidc_issuer @relation(fields: [issuerId], references: [id]) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + signerOption SignerOption +} \ No newline at end of file From 8b25627b06312ec856f3b1a48985dad0b8ba90f3 Mon Sep 17 00:00:00 2001 From: Tipu_Singh Date: Fri, 19 Sep 2025 10:17:48 +0530 Subject: [PATCH 2/8] feat: oidc issunace Signed-off-by: Tipu_Singh --- .../src/agent-service.controller.ts | 26 ++- .../src/agent-service.service.ts | 52 +++++ .../src/issuance/dtos/issuer-sessions.dto.ts | 157 ++++++------- .../issuance/dtos/oidc-issuer-template.dto.ts | 6 +- .../src/issuance/dtos/oidc-issuer.dto.ts | 3 +- .../src/issuance/issuance.service.ts | 31 ++- .../src/issuance/oidc.controller.ts | 123 +++++++++- .../oidc-issuer-sessions.interfaces.ts | 18 +- .../helpers/credential-sessions.builder.ts | 87 ++++++- apps/issuance/src/issuance.controller.ts | 53 ++++- apps/issuance/src/oidc-issuance.service.ts | 215 ++++++++++++++++-- libs/common/src/common.constant.ts | 8 +- libs/common/src/common.utils.ts | 6 +- libs/common/src/nats.config.ts | 4 +- libs/common/src/response-messages/index.ts | 14 +- libs/enum/src/enum.ts | 13 ++ 16 files changed, 691 insertions(+), 125 deletions(-) diff --git a/apps/agent-service/src/agent-service.controller.ts b/apps/agent-service/src/agent-service.controller.ts index dcd3cf149..5cb8f5df0 100644 --- a/apps/agent-service/src/agent-service.controller.ts +++ b/apps/agent-service/src/agent-service.controller.ts @@ -346,7 +346,7 @@ export class AgentServiceController { return this.agentServiceService.oidcGetIssuerById(payload.url, payload.orgId); } - @MessagePattern({ cmd: 'oidc-get-issuers' }) + @MessagePattern({ cmd: 'oidc-get-issuers-agent-service' }) // eslint-disable-next-line @typescript-eslint/no-explicit-any async oidcGetIssuers(payload: { url: string; orgId: string }): Promise { return this.agentServiceService.oidcGetIssuers(payload.url, payload.orgId); @@ -357,4 +357,28 @@ export class AgentServiceController { async oidcCreateCredentialOffer(payload: { credentialPayload; url: string; orgId: string }): Promise { return this.agentServiceService.oidcCreateCredentialOffer(payload.credentialPayload, payload.url, payload.orgId); } + + @MessagePattern({ cmd: 'agent-service-oidc-update-credential-offer' }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async oidcUpdateCredentialOffer(payload: { issuanceMetadata; url: string; orgId: string }): Promise { + return this.agentServiceService.oidcUpdateCredentialOffer(payload.issuanceMetadata, payload.url, payload.orgId); + } + + @MessagePattern({ cmd: 'agent-service-oidc-get-credential-offer-by-id' }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async oidcGetCredentialOfferById(payload: { url: string; orgId: string; offerId: string }): Promise { + return this.agentServiceService.oidcGetCredentialOfferById(payload.url, payload.orgId); + } + + @MessagePattern({ cmd: 'agent-service-oidc-get-all-credential-offers' }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async oidcGetAllCredentialOffers(payload: { url: string; orgId: string }): Promise { + return this.agentServiceService.oidcGetAllCredentialOffers(payload.url, payload.orgId); + } + + @MessagePattern({ cmd: 'agent-service-oidc-delete-credential-offer' }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async oidcDeleteCredentialOffer(payload: { url: string; orgId: string }): Promise { + return this.agentServiceService.oidcDeleteCredentialOffer(payload.url, payload.orgId); + } } diff --git a/apps/agent-service/src/agent-service.service.ts b/apps/agent-service/src/agent-service.service.ts index 7a4709b2b..c2e16bf5b 100644 --- a/apps/agent-service/src/agent-service.service.ts +++ b/apps/agent-service/src/agent-service.service.ts @@ -1475,6 +1475,58 @@ export class AgentServiceService { } } + async oidcUpdateCredentialOffer(issuanceMetadata, url: string, orgId: string): Promise { + try { + const getApiKey = await this.getOrgAgentApiKey(orgId); + const data = await this.commonService + .httpPut(url, issuanceMetadata, { headers: { authorization: getApiKey } }) + .then(async (response) => response); + return data; + } catch (error) { + this.logger.error(`Error in _oidcUpdateCredentialOffer in agent service : ${JSON.stringify(error)}`); + throw error; + } + } + + async oidcGetCredentialOfferById(url: string, orgId: string): Promise { + try { + const getApiKey = await this.getOrgAgentApiKey(orgId); + const data = await this.commonService + .httpGet(url, { headers: { authorization: getApiKey } }) + .then(async (response) => response); + return data; + } catch (error) { + this.logger.error(`Error in _oidcGetCredentialOfferById in agent service : ${JSON.stringify(error)}`); + throw error; + } + } + + async oidcGetAllCredentialOffers(url: string, orgId: string): Promise { + try { + const getApiKey = await this.getOrgAgentApiKey(orgId); + const data = await this.commonService + .httpGet(url, { headers: { authorization: getApiKey } }) + .then(async (response) => response); + return data; + } catch (error) { + this.logger.error(`Error in _oidcGetAllCredentialOffers in agent service : ${JSON.stringify(error)}`); + throw error; + } + } + + async oidcDeleteCredentialOffer(url: string, orgId: string): Promise { + try { + const getApiKey = await this.getOrgAgentApiKey(orgId); + const data = await this.commonService + .httpDelete(`${url}`, { headers: { authorization: getApiKey } }) + .then(async (response) => response); + return data; + } catch (error) { + this.logger.error(`Error in _oidcDeleteCredentialOffer 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/issuance/dtos/issuer-sessions.dto.ts b/apps/api-gateway/src/issuance/dtos/issuer-sessions.dto.ts index a31c65799..e28d6d282 100644 --- a/apps/api-gateway/src/issuance/dtos/issuer-sessions.dto.ts +++ b/apps/api-gateway/src/issuance/dtos/issuer-sessions.dto.ts @@ -1,21 +1,18 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ /* eslint-disable @typescript-eslint/naming-convention, @typescript-eslint/explicit-function-return-type, @typescript-eslint/explicit-module-boundary-types, camelcase */ - import { IsArray, - IsEnum, IsNotEmpty, IsObject, IsOptional, IsString, - Matches, ValidateNested, registerDecorator, ValidationOptions, + ArrayMinSize, IsInt, Min, IsIn, - ArrayMinSize, IsUrl, ValidatorConstraint, ValidatorConstraintInterface, @@ -25,17 +22,13 @@ import { import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; +/* ========= Enums ========= */ export enum CredentialFormat { SdJwtVc = 'vc+sd-jwt', Mdoc = 'mdoc' } -export enum SignerMethodOption { - DID = 'did', - X5C = 'x5c' -} - -/** ---------- custom validator: disclosureFrame ---------- */ +/* ========= disclosureFrame custom validator ========= */ function isDisclosureFrameValue(v: unknown): boolean { if ('boolean' === typeof v) { return true; @@ -57,7 +50,7 @@ export function IsDisclosureFrame(options?: ValidationOptions) { validate(value: unknown) { if (value === undefined) { return true; - } + } // optional if (!value || 'object' !== typeof value || Array.isArray(value)) { return false; } @@ -71,66 +64,7 @@ export function IsDisclosureFrame(options?: ValidationOptions) { }; } -/** ---------- payload DTOs ---------- */ -export class CredentialPayloadDto { - @ApiPropertyOptional() - @IsOptional() - @IsString() - vct?: string; - - @ApiPropertyOptional({ example: 'Garry' }) - @IsOptional() - @IsString() - full_name?: string; - - @ApiPropertyOptional({ example: '2000-01-01', description: 'YYYY-MM-DD' }) - @IsOptional() - @Matches(/^\d{4}-\d{2}-\d{2}$/, { message: 'birth_date must be YYYY-MM-DD' }) - birth_date?: string; - - @ApiPropertyOptional({ example: 'Africa' }) - @IsOptional() - @IsString() - birth_place?: string; - - @ApiPropertyOptional({ example: 'James Bear' }) - @IsOptional() - @IsString() - parent_names?: string; - - [key: string]: unknown; -} - -export class CredentialRequestDto { - @ApiProperty({ example: '1b2d3c4e-...' }) - @IsString() - @IsNotEmpty() - templateId!: string; - - @ApiProperty({ enum: CredentialFormat, example: CredentialFormat.SdJwtVc }) - @IsEnum(CredentialFormat) - format!: CredentialFormat; - - @ApiProperty({ - type: CredentialPayloadDto, - description: 'Credential payload (structure depends on the format)' - }) - @ValidateNested() - @Type(() => CredentialPayloadDto) - payload!: CredentialPayloadDto; - - @ApiPropertyOptional({ - description: 'Selective disclosure frame (claim -> boolean or nested map).', - example: { full_name: true, birth_date: true, birth_place: false, parent_names: false }, - required: false - }) - @IsOptional() - @IsObject() - @IsDisclosureFrame() - disclosureFrame?: Record>; -} - -/** ---------- auth-config DTOs ---------- */ +/* ========= Auth flow DTOs ========= */ export class TxCodeDto { @ApiPropertyOptional({ example: 'test abc' }) @IsOptional() @@ -164,14 +98,14 @@ export class PreAuthorizedCodeFlowConfigDto { export class AuthorizationCodeFlowConfigDto { @ApiProperty({ - example: 'https://id.credebl.ae:8443/realms/credebl', + example: 'https://id.example.com/realms/issuer', description: 'AS (Authorization Server) base URL' }) @IsUrl({ require_tld: false }) authorizationServerUrl!: string; } -/** ---------- class-level constraint: EXACTLY ONE of the two configs ---------- */ +/* ========= XOR class-level validator (exactly one config) ========= */ @ValidatorConstraint({ name: 'ExactlyOneOf', async: false }) class ExactlyOneOfConstraint implements ValidatorConstraintInterface { validate(_: unknown, args: ValidationArguments) { @@ -189,7 +123,35 @@ function ExactlyOneOf(keys: string[], options?: ValidationOptions) { return Validate(ExactlyOneOfConstraint, keys, options); } -/** ---------- root DTO (no authenticationType) ---------- */ +/* ========= Request DTOs ========= */ +export class CredentialRequestDto { + @ApiProperty({ + example: 'c49bdee0-d028-4595-85dc-177c85ea391c', + description: 'Must match credential template id' + }) + @IsString() + @IsNotEmpty() + templateId!: string; + + @ApiProperty({ + description: 'Dynamic claims object', + example: { name: 'Garry', DOB: '2000-01-01', additionalProp3: 'Africa' }, + type: 'object', + additionalProperties: true + }) + @IsObject() + payload!: Record; + + @ApiPropertyOptional({ + description: 'Selective disclosure: claim -> boolean (or nested map)', + example: { name: true, DOB: true, additionalProp3: false }, + required: false + }) + @IsOptional() + @IsDisclosureFrame() + disclosureFrame?: Record>; +} + export class CreateOidcCredentialOfferDto { @ApiProperty({ type: [CredentialRequestDto], @@ -201,14 +163,13 @@ export class CreateOidcCredentialOfferDto { @Type(() => CredentialRequestDto) credentials!: CredentialRequestDto[]; - // Each is optional individually; XOR rule below enforces exactly one present. + // XOR: exactly one present @ApiPropertyOptional({ type: PreAuthorizedCodeFlowConfigDto }) @IsOptional() @ValidateNested() @Type(() => PreAuthorizedCodeFlowConfigDto) preAuthorizedCodeFlowConfig?: PreAuthorizedCodeFlowConfigDto; - @ApiPropertyOptional({ type: AuthorizationCodeFlowConfigDto }) @IsOptional() @ValidateNested() @Type(() => AuthorizationCodeFlowConfigDto) @@ -216,9 +177,51 @@ export class CreateOidcCredentialOfferDto { issuerId?: string; - // Host the class-level XOR validator on a dummy property + // host XOR rule @ExactlyOneOf(['preAuthorizedCodeFlowConfig', 'authorizationCodeFlowConfig'], { message: 'Provide exactly one of preAuthorizedCodeFlowConfig or authorizationCodeFlowConfig.' }) private readonly _exactlyOne?: unknown; } + +export class GetAllCredentialOfferDto { + @ApiProperty({ required: false, example: 'credebl university' }) + @IsOptional() + publicIssuerId: string = ''; + + @ApiProperty({ required: false, example: '568345' }) + @IsOptional() + preAuthorizedCode: string = ''; + + // @ApiPropertyOptional({ + // example: OpenId4VcIssuanceSessionState.OfferCreated, + // enum: OpenId4VcIssuanceSessionState, + // required: false, + // }) + // @IsOptional() + // @IsEnum(OpenId4VcIssuanceSessionState) + // state?: OpenId4VcIssuanceSessionState; + + @ApiProperty({ required: false, example: 'openid-credential-offer://?credential_offer_uri=http%3A%2F%2.....' }) + @IsOptional() + credentialOfferUri: string = ''; + + @ApiProperty({ required: false, example: 'Bob' }) + @IsOptional() + authorizationCode: string = ''; +} + +export class UpdateCredentialRequestDto { + @ApiPropertyOptional({ + description: 'Issuer metadata (any valid JSON object)', + type: 'object', + additionalProperties: true + }) + @IsOptional() + @IsObject() + issuerMetadata?: Record; + + issuerId?: string; + + credentialOfferId?: string; +} diff --git a/apps/api-gateway/src/issuance/dtos/oidc-issuer-template.dto.ts b/apps/api-gateway/src/issuance/dtos/oidc-issuer-template.dto.ts index efd901a89..3fd6bc3c2 100644 --- a/apps/api-gateway/src/issuance/dtos/oidc-issuer-template.dto.ts +++ b/apps/api-gateway/src/issuance/dtos/oidc-issuer-template.dto.ts @@ -125,9 +125,9 @@ export class CreateCredentialTemplateDto { @IsEnum(SignerOption) signerOption!: SignerOption; - @ApiProperty({ enum: ['sd-jwt-vc', 'mdoc'], description: 'Credential format type' }) - @IsEnum(['sd-jwt-vc', 'mso_mdoc', 'vc+sd-jwt']) - format: 'sd-jwt-vc' | 'mso_mdoc ' | 'vc+sd-jwt'; + @ApiProperty({ enum: ['mso_mdoc', 'vc+sd-jwt'], description: 'Credential format type' }) + @IsEnum(['mso_mdoc', 'vc+sd-jwt']) + format: 'mso_mdoc' | 'vc+sd-jwt'; @ApiProperty({ default: false, description: 'Indicates whether credentials can be revoked' }) @IsBoolean() diff --git a/apps/api-gateway/src/issuance/dtos/oidc-issuer.dto.ts b/apps/api-gateway/src/issuance/dtos/oidc-issuer.dto.ts index 89216fcb7..ce54603fa 100644 --- a/apps/api-gateway/src/issuance/dtos/oidc-issuer.dto.ts +++ b/apps/api-gateway/src/issuance/dtos/oidc-issuer.dto.ts @@ -248,9 +248,10 @@ export class IssuerCreationDto { description: 'Configuration of the authorization server', type: AuthorizationServerConfigDto }) + @IsOptional() @ValidateNested() @Type(() => AuthorizationServerConfigDto) - authorizationServerConfigs: AuthorizationServerConfigDto; + authorizationServerConfigs?: AuthorizationServerConfigDto; } export class IssuerUpdationDto { diff --git a/apps/api-gateway/src/issuance/issuance.service.ts b/apps/api-gateway/src/issuance/issuance.service.ts index 018285844..7b620e777 100644 --- a/apps/api-gateway/src/issuance/issuance.service.ts +++ b/apps/api-gateway/src/issuance/issuance.service.ts @@ -31,7 +31,11 @@ import { IssueCredentialDto } from './dtos/multi-connection.dto'; import { oidc_issuer, user } from '@prisma/client'; import { NATSClient } from '@credebl/common/NATSClient'; import { CreateCredentialTemplateDto, UpdateCredentialTemplateDto } from './dtos/oidc-issuer-template.dto'; -import { CreateOidcCredentialOfferDto } from './dtos/issuer-sessions.dto'; +import { + CreateOidcCredentialOfferDto, + GetAllCredentialOfferDto, + UpdateCredentialRequestDto +} from './dtos/issuer-sessions.dto'; import { IssuerCreationDto, IssuerUpdationDto } from './dtos/oidc-issuer.dto'; @Injectable() export class IssuanceService extends BaseService { @@ -299,7 +303,7 @@ export class IssuanceService extends BaseService { async oidcGetIssuers(orgId: string) { const payload = { orgId }; - return this.natsClient.sendNatsMessage(this.issuanceProxy, 'oidc-get-issuers', payload); + return this.natsClient.sendNatsMessage(this.issuanceProxy, 'oidc-get-issuers-issuance', payload); } async oidcDeleteIssuer(userDetails: user, orgId: string, issuerId: string) { @@ -353,6 +357,29 @@ export class IssuanceService extends BaseService { return this.natsClient.sendNatsMessage(this.issuanceProxy, 'oidc-create-credential-offer', payload); } + async updateOidcCredentialOffer( + oidcUpdateCredentialPayload: UpdateCredentialRequestDto, + orgId: string, + issuerId: string + ) { + const payload = { oidcUpdateCredentialPayload, orgId, issuerId }; + return this.natsClient.sendNatsMessage(this.issuanceProxy, 'oidc-update-credential-offer', payload); + } + + async getCredentialOfferDetailsById(offerId: string, orgId: string) { + const payload = { offerId, orgId }; + return this.natsClient.sendNatsMessage(this.issuanceProxy, 'oidc-credential-offer-get-by-id', payload); + } + async getAllCredentialOffers(orgId: string, getAllCredentialOffer: GetAllCredentialOfferDto) { + const payload = { orgId, getAllCredentialOffer }; + return this.natsClient.sendNatsMessage(this.issuanceProxy, 'oidc-credential-offer-get-all', payload); + } + + async deleteCredentialOffers(orgId: string, credentialId: string) { + const payload = { orgId, credentialId }; + return this.natsClient.sendNatsMessage(this.issuanceProxy, 'oidc-credential-offer-delete', payload); + } + oidcIssueCredentialWebhook( oidcIssueCredentialDto, id: string diff --git a/apps/api-gateway/src/issuance/oidc.controller.ts b/apps/api-gateway/src/issuance/oidc.controller.ts index b37cc4cb4..82bf3d1f7 100644 --- a/apps/api-gateway/src/issuance/oidc.controller.ts +++ b/apps/api-gateway/src/issuance/oidc.controller.ts @@ -16,7 +16,9 @@ import { BadRequestException, ParseUUIDPipe, Delete, - Patch + Patch, + Query, + Put } from '@nestjs/common'; import { ApiTags, @@ -44,8 +46,11 @@ import { CustomExceptionFilter } from 'apps/api-gateway/common/exception-handler import { user } from '@prisma/client'; import { IssuerCreationDto, IssuerUpdationDto } from './dtos/oidc-issuer.dto'; import { CreateCredentialTemplateDto, UpdateCredentialTemplateDto } from './dtos/oidc-issuer-template.dto'; -import { CreateOidcCredentialOfferDto } from './dtos/issuer-sessions.dto'; -import { IssuanceDto } from './dtos/issuance.dto'; +import { + CreateOidcCredentialOfferDto, + GetAllCredentialOfferDto, + UpdateCredentialRequestDto +} from './dtos/issuer-sessions.dto'; @Controller() @UseFilters(CustomExceptionFilter) @ApiTags('OIDC') @@ -60,6 +65,7 @@ export class OidcController { * @param res The response object * @returns The status of the deletion operation */ + @Post('/orgs/:orgId/oidc/issuers') @ApiOperation({ summary: 'Create OIDC issuer', description: 'Create OIDC issuer by orgId' }) @ApiResponse({ status: HttpStatus.OK, description: 'Success', type: ApiResponseDto }) @@ -122,7 +128,7 @@ export class OidcController { } @Get('/orgs/:orgId/oidc/issuers/:issuerId') - @ApiOperation({ summary: 'Get OIDC issuer', description: 'Get OIDC issuer by orgId' }) + @ApiOperation({ summary: 'Get OIDC issuer', description: 'Get OIDC issuer by issuerId' }) @ApiResponse({ status: HttpStatus.OK, description: 'Success', type: ApiResponseDto }) @ApiBearerAuth() @Roles(OrgRoles.OWNER) @@ -170,11 +176,11 @@ export class OidcController { ): Promise { const oidcIssuer = await this.issueCredentialService.oidcGetIssuers(orgId); const finalResponse: IResponse = { - statusCode: HttpStatus.CREATED, + statusCode: HttpStatus.OK, message: ResponseMessages.oidcIssuer.success.fetch, data: oidcIssuer }; - return res.status(HttpStatus.CREATED).json(finalResponse); + return res.status(HttpStatus.OK).json(finalResponse); } @Delete('/orgs/:orgId/oidc/:issuerId') @@ -317,7 +323,7 @@ export class OidcController { @User() user: user, @Res() res: Response ): Promise { - const template = await this.issueCredentialService.findByIdTemplate(user, orgId, templateId, issuerId); + const template = await this.issueCredentialService.findByIdTemplate(user, orgId, templateId); const finalResponse: IResponse = { statusCode: HttpStatus.OK, @@ -412,8 +418,7 @@ export class OidcController { @User() user: user, @Res() res: Response ): Promise { - await this.issueCredentialService.deleteTemplate(user, orgId, templateId, issuerId); - + await this.issueCredentialService.deleteTemplate(user, orgId, templateId); const finalResponse: IResponse = { statusCode: HttpStatus.OK, message: ResponseMessages.oidcTemplate.success.delete @@ -441,6 +446,7 @@ export class OidcController { @Res() res: Response ): Promise { oidcCredentialPayload.issuerId = issuerId; + console.log('This is dto', JSON.stringify(oidcCredentialPayload, null, 2)); const template = await this.issueCredentialService.createOidcCredentialOffer( oidcCredentialPayload, user, @@ -449,13 +455,110 @@ export class OidcController { ); const finalResponse: IResponse = { statusCode: HttpStatus.CREATED, - message: ResponseMessages.oidcTemplate.success.create, + message: ResponseMessages.oidcIssuerSession.success.create, data: template }; return res.status(HttpStatus.CREATED).json(finalResponse); } + @Put('/orgs/:orgId/oidc/:issuerId/:credentialId/update-offer') + @ApiResponse({ status: HttpStatus.OK, description: 'Success', type: ApiResponseDto }) + @ApiBearerAuth() + @Roles(OrgRoles.OWNER) + @UseGuards(AuthGuard('jwt'), OrgRolesGuard) + async updateCredentialOffers( + @Body() oidcUpdateCredentialPayload: UpdateCredentialRequestDto, + @Param('orgId') orgId: string, + @Param('issuerId') issuerId: string, + @Param('credentialId') credentialId: string, + @Res() res: Response + ): Promise { + oidcUpdateCredentialPayload.issuerId = issuerId; + oidcUpdateCredentialPayload.credentialOfferId = credentialId; + const updateCredentialOffer = await this.issueCredentialService.updateOidcCredentialOffer( + oidcUpdateCredentialPayload, + orgId, + issuerId + ); + + const finalResponse: IResponse = { + statusCode: HttpStatus.OK, + message: ResponseMessages.oidcIssuerSession.success.update, + data: updateCredentialOffer + }; + return res.status(HttpStatus.OK).json(finalResponse); + } + + @Get('/orgs/:orgId/oidc/credential-offer/:id') + @ApiOperation({ summary: 'Get OIDC credential offer', description: 'Get OIDC credential offer by id' }) + @ApiResponse({ status: HttpStatus.OK, description: 'Success', type: ApiResponseDto }) + @ApiBearerAuth() + @Roles(OrgRoles.OWNER) + @UseGuards(AuthGuard('jwt'), OrgRolesGuard) + async getCredentialOfferDetailsById( + @Param( + 'orgId', + new ParseUUIDPipe({ + exceptionFactory: (): Error => { + throw new BadRequestException(ResponseMessages.organisation.error.invalidOrgId); + } + }) + ) + orgId: string, + @Param('id') + id: string, + @Res() res: Response + ): Promise { + const oidcIssuer = await this.issueCredentialService.getCredentialOfferDetailsById(id, orgId); + const finalResponse: IResponse = { + statusCode: HttpStatus.OK, + message: ResponseMessages.oidcIssuerSession.success.getById, + data: oidcIssuer + }; + return res.status(HttpStatus.OK).json(finalResponse); + } + + @Get('/orgs/:orgId/oidc/credential-offer') + @ApiResponse({ status: HttpStatus.OK, description: 'Success', type: ApiResponseDto }) + @ApiBearerAuth() + @Roles(OrgRoles.OWNER) + @UseGuards(AuthGuard('jwt'), OrgRolesGuard) + async getAllCredentialOffers( + @Query() getAllCredentialOffer: GetAllCredentialOfferDto, + @Param('orgId') orgId: string, + @Res() res: Response + ): Promise { + const connectionDetails = await this.issueCredentialService.getAllCredentialOffers(orgId, getAllCredentialOffer); + + const finalResponse: IResponse = { + statusCode: HttpStatus.OK, + message: ResponseMessages.oidcIssuerSession.success.getAll, + data: connectionDetails + }; + return res.status(HttpStatus.OK).json(finalResponse); + } + + @Delete('/orgs/:orgId/oidc/:credentialId/delete-offer') + @ApiResponse({ status: HttpStatus.OK, description: 'Success', type: ApiResponseDto }) + @ApiBearerAuth() + @Roles(OrgRoles.OWNER) + @UseGuards(AuthGuard('jwt'), OrgRolesGuard) + async deleteCredentialOffers( + @Param('orgId') orgId: string, + @Param('credentialId') credentialId: string, + @Res() res: Response + ): Promise { + const deletedofferDetails = await this.issueCredentialService.deleteCredentialOffers(orgId, credentialId); + + const finalResponse: IResponse = { + statusCode: HttpStatus.NO_CONTENT, + message: ResponseMessages.oidcIssuerSession.success.delete, + data: deletedofferDetails + }; + return res.status(HttpStatus.NO_CONTENT).json(finalResponse); + } + /** * Catch issue credential webhook responses * @param oidcIssueCredentialDto The details of the oidc issued credential diff --git a/apps/issuance/interfaces/oidc-issuer-sessions.interfaces.ts b/apps/issuance/interfaces/oidc-issuer-sessions.interfaces.ts index e6db80dc0..6fd33ead5 100644 --- a/apps/issuance/interfaces/oidc-issuer-sessions.interfaces.ts +++ b/apps/issuance/interfaces/oidc-issuer-sessions.interfaces.ts @@ -1,3 +1,5 @@ +import { OpenId4VcIssuanceSessionState } from '@credebl/enum/enum'; + /* --------------------------------------------------------- * Enums * --------------------------------------------------------- */ @@ -36,7 +38,7 @@ export interface CredentialPayload { } export interface CredentialRequest { - credentialSupportedId: string; + credentialSupportedId?: string; templateId: string; format: CredentialFormat; // "vc+sd-jwt" | "mso_mdoc" payload: CredentialPayload; // user-supplied payload (without vct) @@ -49,3 +51,17 @@ export interface CreateOidcCredentialOffer { authenticationType: AuthenticationType; // only option selector credentials: CredentialRequest[]; // one or more credentials } + +export interface GetAllCredentialOffer { + publicIssuerId?: string; + preAuthorizedCode?: string; + state?: OpenId4VcIssuanceSessionState; + credentialOfferUri?: string; + authorizationCode?: string; +} + +export interface UpdateCredentialRequest { + issuerId: string; + credentialOfferId: string; + issuerMetadata: object; +} diff --git a/apps/issuance/libs/helpers/credential-sessions.builder.ts b/apps/issuance/libs/helpers/credential-sessions.builder.ts index 6764e0634..cecd07ab6 100644 --- a/apps/issuance/libs/helpers/credential-sessions.builder.ts +++ b/apps/issuance/libs/helpers/credential-sessions.builder.ts @@ -1,6 +1,7 @@ // builder/credential-offer.builder.ts /* eslint-disable @typescript-eslint/naming-convention, @typescript-eslint/explicit-function-return-type, @typescript-eslint/explicit-module-boundary-types, camelcase */ import { Prisma, credential_templates } from '@prisma/client'; +import { GetAllCredentialOffer } from 'apps/issuance/interfaces/oidc-issuer-sessions.interfaces'; /* ============================================================================ Domain Types @@ -75,7 +76,7 @@ export interface BuiltCredential { export interface BuiltCredentialOfferBase { /** Resolved signer option (DID or x5c) */ - signerOption: ResolvedSignerOption; + signerOption?: ResolvedSignerOption; /** Normalized credential entries */ credentials: BuiltCredential[]; /** Optional public issuer id to include */ @@ -111,7 +112,7 @@ const isRecord = (v: unknown): v is Record => Boolean(v) && 'ob /** Map DB format string -> API enum */ function mapDbFormatToApiFormat(db: string): CredentialFormat { const norm = db.toLowerCase().replace(/_/g, '-'); - if ('sd-jwt' === norm || 'vc+sd-jwt' === norm || 'sdjwt' === norm) { + if ('sd-jwt' === norm || 'vc+sd-jwt' === norm || 'sdjwt' === norm || 'sd+jwt-vc' === norm) { return CredentialFormat.SdJwtVc; } if ('mdoc' === norm || 'mso-mdoc' === norm || 'mso-mdoc' === norm) { @@ -225,7 +226,7 @@ function buildOneCredential( export function buildCredentialOfferPayload( dto: CreateOidcCredentialOfferDtoLike, templates: credential_templates[], - signerOption: ResolvedSignerOption + signerOption?: ResolvedSignerOption // <-- now optional ): CredentialOfferPayload { // Index templates const byId = new Map(templates.map((t) => [t.id, t])); @@ -245,9 +246,9 @@ export function buildCredentialOfferPayload( // --- Base envelope (issuerId deliberately NOT included) --- const base: BuiltCredentialOfferBase = { - signerOption, // resolved keys (did/x5c) from DB credentials, - ...(dto.publicIssuerId ? { publicIssuerId: dto.publicIssuerId } : {}) + ...(dto.publicIssuerId ? { publicIssuerId: dto.publicIssuerId } : {}), + ...(signerOption ? { signerOption } : {}) // <-- add only if provided }; // XOR flow selection (defensive) @@ -269,3 +270,79 @@ export function buildCredentialOfferPayload( authorizationCodeFlowConfig: dto.authorizationCodeFlowConfig! // definite since !hasPre }; } + +// ----------------------------------------------------------------------------- +// Builder: Update Credential Offer +// ----------------------------------------------------------------------------- +export function buildUpdateCredentialOfferPayload( + dto: CreateOidcCredentialOfferDtoLike, + templates: credential_templates[] +): { credentials: BuiltCredential[] } { + // Index templates by id + const byId = new Map(templates.map((t) => [t.id, t])); + + // Validate all templateIds exist + const unknown = dto.credentials.map((c) => c.templateId).filter((id) => !byId.has(id)); + if (unknown.length) { + throw new Error(`Unknown template ids: ${unknown.join(', ')}`); + } + + // Validate each credential against its template + const credentials: BuiltCredential[] = dto.credentials.map((cred) => { + const template = byId.get(cred.templateId)!; + const attrs = ensureTemplateAttributes(template.attributes); // safely narrow JsonValue + + // check that all payload keys exist in template attributes + const payloadKeys = Object.keys(cred.payload); + const invalidKeys = payloadKeys.filter((k) => !attrs[k]); + if (invalidKeys.length) { + throw new Error(`Invalid attributes for template "${cred.templateId}": ${invalidKeys.join(', ')}`); + } + + // also validate mandatory fields are present + assertMandatoryClaims(cred.payload, attrs, { templateId: cred.templateId }); + + // build minimal normalized credential (no vct, issuerId, etc.) + const apiFormat = mapDbFormatToApiFormat(template.format); + const suffix = formatSuffix(apiFormat); + const credentialSupportedId = `${template.name}-${suffix}`; + return { + credentialSupportedId, + format: apiFormat, + payload: cred.payload, + ...(cred.disclosureFrame ? { disclosureFrame: cred.disclosureFrame } : {}) + }; + }); + + // Only return credentials array here (update flow doesn't need preAuth/auth configs) + return { + credentials + }; +} + +export function buildCredentialOfferUrl(baseUrl: string, getAllCredentialOffer: GetAllCredentialOffer): string { + const criteriaParams: string[] = []; + + if (getAllCredentialOffer.publicIssuerId) { + criteriaParams.push(`publicIssuerId=${encodeURIComponent(getAllCredentialOffer.publicIssuerId)}`); + } + + if (getAllCredentialOffer.preAuthorizedCode) { + criteriaParams.push(`preAuthorizedCode=${encodeURIComponent(getAllCredentialOffer.preAuthorizedCode)}`); + } + + if (getAllCredentialOffer.state) { + criteriaParams.push(`state=${encodeURIComponent(getAllCredentialOffer.state)}`); + } + + if (getAllCredentialOffer.credentialOfferUri) { + criteriaParams.push(`credentialOfferUri=${encodeURIComponent(getAllCredentialOffer.credentialOfferUri)}`); + } + + if (getAllCredentialOffer.authorizationCode) { + criteriaParams.push(`authorizationCode=${encodeURIComponent(getAllCredentialOffer.authorizationCode)}`); + } + + // Append query string if any params exist + return 0 < criteriaParams.length ? `${baseUrl}?${criteriaParams.join('&')}` : baseUrl; +} diff --git a/apps/issuance/src/issuance.controller.ts b/apps/issuance/src/issuance.controller.ts index 6538c39fd..b5c290dbf 100644 --- a/apps/issuance/src/issuance.controller.ts +++ b/apps/issuance/src/issuance.controller.ts @@ -24,7 +24,11 @@ import { OOBIssueCredentialDto } from 'apps/api-gateway/src/issuance/dtos/issuan import { credential_templates, oidc_issuer, user } from '@prisma/client'; import { CreateCredentialTemplate, UpdateCredentialTemplate } from '../interfaces/oidc-template.interface'; import { IssuerCreation, IssuerUpdation } from '../interfaces/oidc-issuance.interfaces'; -import { CreateOidcCredentialOffer } from '../interfaces/oidc-issuer-sessions.interfaces'; +import { + CreateOidcCredentialOffer, + GetAllCredentialOffer, + UpdateCredentialRequest +} from '../interfaces/oidc-issuer-sessions.interfaces'; import { OIDCIssuanceService } from './oidc-issuance.service'; @Controller() @@ -173,7 +177,7 @@ export class IssuanceController { return this.oidcIssuanceService.oidcIssuerGetById(issuerId, orgId); } - @MessagePattern({ cmd: 'oidc-get-issuers' }) + @MessagePattern({ cmd: 'oidc-get-issuers-issuance' }) async oidcGetIssuers(payload: { orgId: string }): Promise { const { orgId } = payload; return this.oidcIssuanceService.oidcIssuers(orgId); @@ -249,11 +253,56 @@ export class IssuanceController { orgId: string; userDetails: user; issuerId: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any }): Promise { const { oidcCredentialPayload, orgId, userDetails, issuerId } = payload; return this.oidcIssuanceService.createOidcCredentialOffer(oidcCredentialPayload, orgId, userDetails, issuerId); } + @MessagePattern({ cmd: 'oidc-update-credential-offer' }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async updateOidcCredentialOffer(payload: { + oidcUpdateCredentialPayload: UpdateCredentialRequest; + orgId: string; + issuerId: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }): Promise { + const { oidcUpdateCredentialPayload, orgId, issuerId } = payload; + return this.oidcIssuanceService.updateOidcCredentialOffer(oidcUpdateCredentialPayload, orgId, issuerId); + } + + @MessagePattern({ cmd: 'oidc-credential-offer-get-by-id' }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async getCredentialOfferDetailsById(payload: { + offerId: string; + orgId: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }): Promise { + const { offerId, orgId } = payload; + return this.oidcIssuanceService.getCredentialOfferDetailsById(offerId, orgId); + } + @MessagePattern({ cmd: 'oidc-credential-offer-get-all' }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async getAllCredentialOffers(payload: { + orgId: string; + getAllCredentialOffer: GetAllCredentialOffer; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }): Promise { + const { orgId, getAllCredentialOffer } = payload; + return this.oidcIssuanceService.getCredentialOffers(orgId, getAllCredentialOffer); + } + + @MessagePattern({ cmd: 'oidc-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 { + const { orgId, credentialId } = payload; + return this.oidcIssuanceService.deleteCredentialOffers(orgId, credentialId); + } + //TODO: complete the logic @MessagePattern({ cmd: 'webhook-oidc-issue-credential' }) async oidcIssueCredentialWebhook(payload: IssueCredentialWebhookPayload): Promise { diff --git a/apps/issuance/src/oidc-issuance.service.ts b/apps/issuance/src/oidc-issuance.service.ts index da743deb6..1059ff8a6 100644 --- a/apps/issuance/src/oidc-issuance.service.ts +++ b/apps/issuance/src/oidc-issuance.service.ts @@ -46,11 +46,18 @@ import { } from '../libs/helpers/issuer.metadata'; import { CreateOidcCredentialOffer, + GetAllCredentialOffer, SignerMethodOption, - SignerOption + SignerOption, + UpdateCredentialRequest } from '../interfaces/oidc-issuer-sessions.interfaces'; import { BadRequestErrorDto } from 'apps/api-gateway/src/dtos/bad-request-error.dto'; -import { buildCredentialOfferPayload, CredentialOfferPayload } from '../libs/helpers/credential-sessions.builder'; +import { + buildCredentialOfferPayload, + buildCredentialOfferUrl, + buildUpdateCredentialOfferPayload, + CredentialOfferPayload +} from '../libs/helpers/credential-sessions.builder'; type CredentialDisplayItem = { logo?: { uri: string; alt_text?: string }; @@ -86,7 +93,7 @@ export class OIDCIssuanceService { const issuerInitialConfig: IssuerInitialConfig = { issuerId, display: issuerCreation?.display || {}, - authorizationServerConfigs: issuerCreation?.authorizationServerConfigs || {}, + authorizationServerConfigs: issuerCreation?.authorizationServerConfigs || undefined, accessTokenSignerKeyType, dpopSigningAlgValuesSupported, batchCredentialIssuance: { @@ -212,21 +219,31 @@ export class OIDCIssuanceService { } } - async oidcIssuers(orgId: string): Promise { + async oidcIssuers(orgId: string): Promise { try { const agentDetails = await this.issuanceRepository.getAgentEndPoint(orgId); - if (!agentDetails) { + if (!agentDetails?.agentEndPoint) { throw new NotFoundException(ResponseMessages.issuance.error.agentEndPointNotFound); } - const url = await getAgentUrl(agentDetails?.agentEndPoint, CommonConstants.OIDC_GET_ALL_ISSUERS); + + const url = await getAgentUrl(agentDetails.agentEndPoint, CommonConstants.OIDC_GET_ALL_ISSUERS); const issuersDetails = await this._oidcGetIssuers(url, orgId); - if (!issuersDetails) { - throw new InternalServerErrorException(`Error from agent while oidcIssuers`); + if (!issuersDetails || null == issuersDetails.response) { + throw new InternalServerErrorException('Error from agent while oidcIssuers'); } + //TODO: Fix the response type from agent + const raw = issuersDetails.response as unknown; + const response: IssuerResponse[] = + 'string' === typeof raw ? (JSON.parse(raw) as IssuerResponse[]) : (raw as IssuerResponse[]); - return issuersDetails; - } catch (error) { - this.logger.error(`[oidcIssuers] - error in oidcIssuers issuance records: ${JSON.stringify(error)}`); + if (!Array.isArray(response)) { + throw new InternalServerErrorException('Invalid issuer payload from agent'); + } + + return response; + } catch (error: any) { + const msg = error?.message ?? 'unknown error'; + this.logger.error(`[oidcIssuers] - error in oidcIssuers: ${msg}`); throw new RpcException(error?.response ?? error); } } @@ -471,7 +488,7 @@ export class OIDCIssuanceService { const createCredentialOfferOnAgent = await this._oidcCreateCredentialOffer(buildOidcCredentialOffer, url, orgId); if (!createCredentialOfferOnAgent) { - throw new NotFoundException(ResponseMessages.oidcIssuerSession.error.errroCreateOffer); + throw new NotFoundException(ResponseMessages.oidcIssuerSession.error.errorCreateOffer); } return createCredentialOfferOnAgent.response; @@ -480,6 +497,126 @@ export class OIDCIssuanceService { throw new RpcException(error.response ?? error); } } + + async updateOidcCredentialOffer( + updateOidcCredentialOffer: UpdateCredentialRequest, + orgId: string, + issuerId: string + ): Promise { + try { + if (!updateOidcCredentialOffer.issuerMetadata) { + throw new BadRequestException('Please provide a valid issuerMetadata'); + } + // TODO: Need to implement this in future if required + + // const filterTemplateIds = extractTemplateIds(updateOidcCredentialOffer); + // console.log('This is the filterTemplateIds:', filterTemplateIds); + // if (!filterTemplateIds) { + // throw new BadRequestException('Please provide a valid id'); + // } + // const getAllOfferTemplates = await this.issuanceRepository.getTemplateByIds(filterTemplateIds, issuerId); + // console.log('This is the getAllOfferTemplates:', getAllOfferTemplates); + // if (!getAllOfferTemplates || getAllOfferTemplates.length === 0) { + // throw new NotFoundException('No templates found for the issuer'); + // } + // const buildOidcUpdateCredentialOffer = buildUpdateCredentialOfferPayload( + // updateOidcCredentialOffer, + // getAllOfferTemplates + // ); + // if (!buildOidcUpdateCredentialOffer) { + // throw new BadRequestException('Error while creating oidc credential offer'); + // } + // const agentDetails = await this.issuanceRepository.getAgentEndPoint(orgId); + // if (!agentDetails) { + // throw new NotFoundException(ResponseMessages.oidcTemplate.error.issuerDetailsNotFound); + // } + // const issuanceMetadata= { + // issuanceMetadata: { + // issuerDid: agentDetails.orgDid, + // credentials: buildOidcUpdateCredentialOffer.credentials + // } + // } + const url = await getAgentUrl( + await this.getAgentEndpoint(orgId), + CommonConstants.OIDC_ISSUER_SESSIONS_UPDATE_OFFER, + updateOidcCredentialOffer.credentialOfferId + ); + const updateCredentialOfferOnAgent = await this._oidcUpdateCredentialOffer( + updateOidcCredentialOffer.issuerMetadata, + url, + orgId + ); + console.log('This is the updateCredentialOfferOnAgent:', JSON.stringify(updateCredentialOfferOnAgent)); + if (!updateCredentialOfferOnAgent) { + throw new NotFoundException(ResponseMessages.oidcIssuerSession.error.errorUpdateOffer); + } + + return updateCredentialOfferOnAgent.response; + } catch (error) { + this.logger.error(`[createOidcCredentialOffer] - error: ${JSON.stringify(error)}`); + throw new RpcException(error.response ?? error); + } + } + + async getCredentialOfferDetailsById(offerId: string, orgId: string): Promise { + try { + const url = await getAgentUrl( + await this.getAgentEndpoint(orgId), + CommonConstants.OIDC_ISSUER_SESSIONS_BY_ID, + offerId + ); + const offer = await this._oidcGetCredentialOfferById(url, orgId); + if ('string' === typeof offer.response) { + offer.response = JSON.parse(offer.response); + } + return offer.response; + } catch (error) { + this.logger.error(`[getCredentialOfferDetailsById] - error: ${JSON.stringify(error)}`); + throw new RpcException(error.response ?? error); + } + } + + async getCredentialOffers(orgId: string, getAllCredentialOffer: GetAllCredentialOffer): Promise { + try { + const url = await getAgentUrl(await this.getAgentEndpoint(orgId), CommonConstants.OIDC_ISSUER_SESSIONS); + const credentialOfferUrl = buildCredentialOfferUrl(url, getAllCredentialOffer); + console.log('This is credentialOfferUrl', credentialOfferUrl); + const offers = await this._oidcGetCredentialOfferById(credentialOfferUrl, orgId); + console.log('This is offer', JSON.stringify(offers, null, 2)); + if ('string' === typeof offers.response) { + offers.response = JSON.parse(offers.response); + } + return offers.response; + } catch (error) { + this.logger.error(`[getCredentialOffers] - error: ${JSON.stringify(error)}`); + throw new RpcException(error.response ?? error); + } + } + async deleteCredentialOffers(orgId: string, credentialId: string): Promise { + try { + if (!credentialId) { + throw new BadRequestException('Please provide a valid credentialId'); + } + const url = await getAgentUrl( + await this.getAgentEndpoint(orgId), + CommonConstants.OIDC_DELETE_CREDENTIAL_OFFER, + credentialId + ); + console.log('This is the url:', url); + const deletedCredentialOffer = await this._oidcDeleteCredentialOffer(url, orgId); + if (!deletedCredentialOffer) { + throw new NotFoundException(ResponseMessages.oidcIssuerSession.error.deleteFailed); + } + if ('string' === typeof deletedCredentialOffer.response) { + deletedCredentialOffer.response = JSON.parse(deletedCredentialOffer.response); + } + return deletedCredentialOffer.response; + } catch (error) { + this.logger.error(`[getCredentialOffers] - error: ${JSON.stringify(error)}`); + throw new RpcException(error.response ?? error); + } + } + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type async buildOidcIssuerConfig(issuerId: string) { try { @@ -553,7 +690,7 @@ export class OIDCIssuanceService { async _oidcGetIssuers(url: string, orgId: string) { try { - const pattern = { cmd: 'oidc-get-issuers' }; + const pattern = { cmd: 'oidc-get-issuers-agent-service' }; const payload = { url, orgId }; return this.natsCall(pattern, payload); } catch (error) { @@ -575,6 +712,58 @@ export class OIDCIssuanceService { } } + async _oidcUpdateCredentialOffer(issuanceMetadata, url: string, orgId: string) { + try { + const pattern = { cmd: 'agent-service-oidc-update-credential-offer' }; + const payload = { issuanceMetadata, url, orgId }; + return this.natsCall(pattern, payload); + } catch (error) { + this.logger.error( + `[_oidcUpdateCredentialOffer] [NATS call]- error in oidc update credential offer : ${JSON.stringify(error)}` + ); + throw error; + } + } + + async _oidcGetCredentialOfferById(url: string, orgId: string) { + try { + const pattern = { cmd: 'agent-service-oidc-get-credential-offer-by-id' }; + const payload = { url, orgId }; + return this.natsCall(pattern, payload); + } catch (error) { + this.logger.error( + `[_oidcGetCredentialOfferById] [NATS call]- error in oidc get credential offer by id : ${JSON.stringify(error)}` + ); + throw error; + } + } + + async _oidcGetCredentialOffers(url: string, orgId: string) { + try { + const pattern = { cmd: 'agent-service-oidc-get-credential-offers' }; + const payload = { url, orgId }; + return this.natsCall(pattern, payload); + } catch (error) { + this.logger.error( + `[_oidcGetCredentialOffers] [NATS call]- error in oidc get credential offers : ${JSON.stringify(error)}` + ); + throw error; + } + } + + async _oidcDeleteCredentialOffer(url: string, orgId: string) { + try { + const pattern = { cmd: 'agent-service-oidc-delete-credential-offer' }; + const payload = { url, orgId }; + return this.natsCall(pattern, payload); + } catch (error) { + this.logger.error( + `[_oidcDeleteCredentialOffer] [NATS call]- error in oidc delete credential offer : ${JSON.stringify(error)}` + ); + throw error; + } + } + async natsCall( pattern: object, payload: object diff --git a/libs/common/src/common.constant.ts b/libs/common/src/common.constant.ts index da939394d..323f602c8 100644 --- a/libs/common/src/common.constant.ts +++ b/libs/common/src/common.constant.ts @@ -123,6 +123,8 @@ export enum CommonConstants { URL_OIDC_GET_ISSUES = '/openid4vc/issuer', URL_OIDC_ISSUER_UPDATE = '/openid4vc/issuer/#', URL_OIDC_ISSUER_SESSIONS_CREATE = '/openid4vc/issuance-sessions/create-credential-offer', + URL_OIDC_ISSUER_SESSIONS_GET = '/openid4vc/issuance-sessions/#', + URL_OIDC_ISSUER_SESSIONS_GET_ALL = '/openid4vc/issuance-sessions', // Nested attribute separator NESTED_ATTRIBUTE_SEPARATOR = '~', @@ -398,7 +400,11 @@ export enum CommonConstants { OIDC_GET_ALL_ISSUERS = 'get-all-oidc-issuers', OIDC_ISSUER_BY_ID = 'get-issuer-by-id', OIDC_ISSUER_TEMPLATE = 'create-oidc-template', - OIDC_ISSUER_SESSIONS_CREDENTIAL_OFFER = 'create-oidc-credential-offer' + OIDC_ISSUER_SESSIONS_CREDENTIAL_OFFER = 'create-oidc-credential-offer', + OIDC_ISSUER_SESSIONS_UPDATE_OFFER = 'update-oidc-credential-offer', + OIDC_ISSUER_SESSIONS_BY_ID = 'get-oidc-session-by-id', + OIDC_ISSUER_SESSIONS = 'get-oidc-sessions', + OIDC_DELETE_CREDENTIAL_OFFER = 'delete-oidc-credential-offer' } export const MICRO_SERVICE_NAME = Symbol('MICRO_SERVICE_NAME'); export const ATTRIBUTE_NAME_REGEX = /\['(.*?)'\]/; diff --git a/libs/common/src/common.utils.ts b/libs/common/src/common.utils.ts index b1c70e012..5091239fc 100644 --- a/libs/common/src/common.utils.ts +++ b/libs/common/src/common.utils.ts @@ -102,7 +102,11 @@ export const getAgentUrl = async (agentEndPoint: string, urlFlag: string, paramI [ String(CommonConstants.OIDC_ISSUER_SESSIONS_CREDENTIAL_OFFER), String(CommonConstants.URL_OIDC_ISSUER_SESSIONS_CREATE) - ] + ], + [String(CommonConstants.OIDC_ISSUER_SESSIONS_UPDATE_OFFER), String(CommonConstants.URL_OIDC_ISSUER_SESSIONS_GET)], + [String(CommonConstants.OIDC_ISSUER_SESSIONS_BY_ID), String(CommonConstants.URL_OIDC_ISSUER_SESSIONS_GET)], + [String(CommonConstants.OIDC_ISSUER_SESSIONS), String(CommonConstants.URL_OIDC_ISSUER_SESSIONS_GET_ALL)], + [String(CommonConstants.OIDC_DELETE_CREDENTIAL_OFFER), String(CommonConstants.URL_OIDC_ISSUER_SESSIONS_GET_ALL)] ]); const urlSuffix = agentUrlMap.get(urlFlag); diff --git a/libs/common/src/nats.config.ts b/libs/common/src/nats.config.ts index 7fd1ff011..984459979 100644 --- a/libs/common/src/nats.config.ts +++ b/libs/common/src/nats.config.ts @@ -14,8 +14,8 @@ export const getNatsOptions = ( const baseOptions = { servers: `${process.env.NATS_URL}`.split(','), maxReconnectAttempts: NATSReconnects.maxReconnectAttempts, - reconnectTimeWait: NATSReconnects.reconnectTimeWait, - queue: serviceName + reconnectTimeWait: NATSReconnects.reconnectTimeWait + // queue: serviceName }; if (nkeySeed) { diff --git a/libs/common/src/response-messages/index.ts b/libs/common/src/response-messages/index.ts index 681751427..8b66fc8f4 100644 --- a/libs/common/src/response-messages/index.ts +++ b/libs/common/src/response-messages/index.ts @@ -535,14 +535,16 @@ export const ResponseMessages = { }, oidcIssuerSession: { success: { - create: 'OIDC Credential offer created successfully.' + create: 'OIDC Credential offer created successfully.', + getById: 'OIDC Credential offer details fetched successfully.', + getAll: 'OIDC Credential offers fetched successfully.', + update: 'OIDC Credential offer updated successfully.', + delete: 'OIDC Credential offer deleted successfully.' }, error: { - errroCreateOffer: 'Error while creating OIDC credential offer on agent.', - invalidId: 'Invalid OIDC issuer ID.', - createFailed: 'Failed to create OIDC issuer.', - updateFailed: 'Failed to update OIDC issuer.', - deleteFailed: 'Failed to delete OIDC issuer.' + errorCreateOffer: 'Error while creating OIDC credential offer on agent.', + errorUpdateOffer: 'Error while updating OIDC credential offer on agent.', + deleteFailed: 'Failed to delete OIDC credential offer.' } }, nats: { diff --git a/libs/enum/src/enum.ts b/libs/enum/src/enum.ts index 9dc8b31ca..787fc1f92 100644 --- a/libs/enum/src/enum.ts +++ b/libs/enum/src/enum.ts @@ -271,3 +271,16 @@ export enum ProviderType { KEYCLOAK = 'keycloak', SUPABASE = 'supabase' } + +export declare enum OpenId4VcIssuanceSessionState { + OfferCreated = 'OfferCreated', + OfferUriRetrieved = 'OfferUriRetrieved', + AuthorizationInitiated = 'AuthorizationInitiated', + AuthorizationGranted = 'AuthorizationGranted', + AccessTokenRequested = 'AccessTokenRequested', + AccessTokenCreated = 'AccessTokenCreated', + CredentialRequestReceived = 'CredentialRequestReceived', + CredentialsPartiallyIssued = 'CredentialsPartiallyIssued', + Completed = 'Completed', + Error = 'Error' +} From 1d6938ab1a4014a84ba52b95c71a245030b4a620 Mon Sep 17 00:00:00 2001 From: Tipu_Singh Date: Thu, 25 Sep 2025 10:58:16 +0530 Subject: [PATCH 3/8] feat:create seperate microservice for oid4vc Signed-off-by: Tipu_Singh --- .../src/agent-service.controller.ts | 25 +- .../src/agent-service.service.ts | 12 +- apps/api-gateway/src/app.module.ts | 4 +- .../src/issuance/issuance.controller.ts | 1 - .../src/issuance/issuance.module.ts | 3 +- .../src/issuance/issuance.service.ts | 119 +----- .../dtos/issuer-sessions.dto.ts | 0 .../dtos/oid4vc-credential-wh.dto.ts | 50 +++ .../dtos/oid4vc-issuer-template.dto.ts} | 2 +- .../dtos/oid4vc-issuer.dto.ts} | 11 +- .../oid4vc-issuance.controller.ts} | 212 ++++++----- .../oid4vc-issuance/oid4vc-issuance.module.ts | 24 ++ .../oid4vc-issuance.service.ts | 133 +++++++ apps/issuance/constant/issuance.ts | 2 +- .../helpers/credential-sessions.builder.ts | 4 +- apps/issuance/libs/helpers/issuer.metadata.ts | 4 +- apps/issuance/src/issuance.controller.ts | 177 +-------- apps/issuance/src/issuance.module.ts | 2 - apps/issuance/src/issuance.repository.ts | 209 +---------- apps/oid4vc-issuance/constant/issuance.ts | 6 + apps/oid4vc-issuance/enum/issuance.enum.ts | 29 ++ .../interfaces/oid4vc-issuance.interfaces.ts} | 19 + .../oid4vc-issuer-sessions.interfaces.ts} | 0 .../interfaces/oid4vc-template.interfaces.ts} | 2 +- .../interfaces/oid4vc-wh-interfaces.ts | 48 +++ .../helpers/credential-sessions.builder.ts | 349 ++++++++++++++++++ .../libs/helpers/issuer.metadata.ts | 262 +++++++++++++ apps/oid4vc-issuance/src/main.ts | 23 ++ .../src/oid4vc-issuance.controller.ts | 172 +++++++++ .../src/oid4vc-issuance.module.ts | 36 ++ .../src/oid4vc-issuance.repository.ts | 278 ++++++++++++++ .../src/oid4vc-issuance.service.ts} | 254 ++++++++----- apps/oid4vc-issuance/test/app.e2e-spec.ts | 19 + apps/oid4vc-issuance/test/jest-e2e.json | 9 + apps/oid4vc-issuance/tsconfig.app.json | 9 + libs/common/src/common.constant.ts | 23 +- libs/common/src/response-messages/index.ts | 53 +-- libs/prisma-service/prisma/schema.prisma | 19 +- nest-cli.json | 11 +- package.json | 2 +- tsconfig.json | 3 +- 41 files changed, 1845 insertions(+), 775 deletions(-) rename apps/api-gateway/src/{issuance => oid4vc-issuance}/dtos/issuer-sessions.dto.ts (100%) create mode 100644 apps/api-gateway/src/oid4vc-issuance/dtos/oid4vc-credential-wh.dto.ts rename apps/api-gateway/src/{issuance/dtos/oidc-issuer-template.dto.ts => oid4vc-issuance/dtos/oid4vc-issuer-template.dto.ts} (98%) rename apps/api-gateway/src/{issuance/dtos/oidc-issuer.dto.ts => oid4vc-issuance/dtos/oid4vc-issuer.dto.ts} (95%) rename apps/api-gateway/src/{issuance/oidc.controller.ts => oid4vc-issuance/oid4vc-issuance.controller.ts} (68%) create mode 100644 apps/api-gateway/src/oid4vc-issuance/oid4vc-issuance.module.ts create mode 100644 apps/api-gateway/src/oid4vc-issuance/oid4vc-issuance.service.ts create mode 100644 apps/oid4vc-issuance/constant/issuance.ts create mode 100644 apps/oid4vc-issuance/enum/issuance.enum.ts rename apps/{issuance/interfaces/oidc-issuance.interfaces.ts => oid4vc-issuance/interfaces/oid4vc-issuance.interfaces.ts} (89%) rename apps/{issuance/interfaces/oidc-issuer-sessions.interfaces.ts => oid4vc-issuance/interfaces/oid4vc-issuer-sessions.interfaces.ts} (100%) rename apps/{issuance/interfaces/oidc-template.interface.ts => oid4vc-issuance/interfaces/oid4vc-template.interfaces.ts} (91%) create mode 100644 apps/oid4vc-issuance/interfaces/oid4vc-wh-interfaces.ts create mode 100644 apps/oid4vc-issuance/libs/helpers/credential-sessions.builder.ts create mode 100644 apps/oid4vc-issuance/libs/helpers/issuer.metadata.ts create mode 100644 apps/oid4vc-issuance/src/main.ts create mode 100644 apps/oid4vc-issuance/src/oid4vc-issuance.controller.ts create mode 100644 apps/oid4vc-issuance/src/oid4vc-issuance.module.ts create mode 100644 apps/oid4vc-issuance/src/oid4vc-issuance.repository.ts rename apps/{issuance/src/oidc-issuance.service.ts => oid4vc-issuance/src/oid4vc-issuance.service.ts} (73%) create mode 100644 apps/oid4vc-issuance/test/app.e2e-spec.ts create mode 100644 apps/oid4vc-issuance/test/jest-e2e.json create mode 100644 apps/oid4vc-issuance/tsconfig.app.json diff --git a/apps/agent-service/src/agent-service.controller.ts b/apps/agent-service/src/agent-service.controller.ts index 5cb8f5df0..0c4d7c81b 100644 --- a/apps/agent-service/src/agent-service.controller.ts +++ b/apps/agent-service/src/agent-service.controller.ts @@ -324,59 +324,58 @@ export class AgentServiceController { return this.agentServiceService.getAgentDetails(payload.orgId); } - @MessagePattern({ cmd: 'agent-create-oidc-issuer' }) + @MessagePattern({ cmd: 'agent-create-oid4vc-issuer' }) // eslint-disable-next-line @typescript-eslint/no-explicit-any async oidcIssuerCreate(payload: { issuerCreation; url: string; orgId: string }): Promise { return this.agentServiceService.oidcIssuerCreate(payload.issuerCreation, payload.url, payload.orgId); } - @MessagePattern({ cmd: 'delete-oidc-issuer' }) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - async oidcDeleteIssuer(payload: { url: string; orgId: string }): Promise { + @MessagePattern({ cmd: 'delete-oid4vc-issuer' }) + async oidcDeleteIssuer(payload: { url: string; orgId: string }): Promise { return this.agentServiceService.deleteOidcIssuer(payload.url, payload.orgId); } - @MessagePattern({ cmd: 'agent-create-oidc-template' }) + @MessagePattern({ cmd: 'agent-create-oid4vc-template' }) // eslint-disable-next-line @typescript-eslint/no-explicit-any async oidcIssuerTemplate(payload: { templatePayload; url: string; orgId: string }): Promise { return this.agentServiceService.oidcIssuerTemplate(payload.templatePayload, payload.url, payload.orgId); } - //TODO: change message for oidc - @MessagePattern({ cmd: 'oidc-get-issuer-by-id' }) + //TODO: change message for oid4vc + @MessagePattern({ cmd: 'oid4vc-get-issuer-by-id' }) // eslint-disable-next-line @typescript-eslint/no-explicit-any async oidcGetIssuerById(payload: { url: string; orgId: string }): Promise { return this.agentServiceService.oidcGetIssuerById(payload.url, payload.orgId); } - @MessagePattern({ cmd: 'oidc-get-issuers-agent-service' }) + @MessagePattern({ cmd: 'oid4vc-get-issuers-agent-service' }) // eslint-disable-next-line @typescript-eslint/no-explicit-any async oidcGetIssuers(payload: { url: string; orgId: string }): Promise { return this.agentServiceService.oidcGetIssuers(payload.url, payload.orgId); } - @MessagePattern({ cmd: 'agent-service-oidc-create-credential-offer' }) + @MessagePattern({ cmd: 'agent-service-oid4vc-create-credential-offer' }) // eslint-disable-next-line @typescript-eslint/no-explicit-any async oidcCreateCredentialOffer(payload: { credentialPayload; url: string; orgId: string }): Promise { return this.agentServiceService.oidcCreateCredentialOffer(payload.credentialPayload, payload.url, payload.orgId); } - @MessagePattern({ cmd: 'agent-service-oidc-update-credential-offer' }) + @MessagePattern({ cmd: 'agent-service-oid4vc-update-credential-offer' }) // eslint-disable-next-line @typescript-eslint/no-explicit-any async oidcUpdateCredentialOffer(payload: { issuanceMetadata; url: string; orgId: string }): Promise { return this.agentServiceService.oidcUpdateCredentialOffer(payload.issuanceMetadata, payload.url, payload.orgId); } - @MessagePattern({ cmd: 'agent-service-oidc-get-credential-offer-by-id' }) + @MessagePattern({ cmd: 'agent-service-oid4vc-get-credential-offer-by-id' }) // eslint-disable-next-line @typescript-eslint/no-explicit-any async oidcGetCredentialOfferById(payload: { url: string; orgId: string; offerId: string }): Promise { return this.agentServiceService.oidcGetCredentialOfferById(payload.url, payload.orgId); } - @MessagePattern({ cmd: 'agent-service-oidc-get-all-credential-offers' }) + @MessagePattern({ cmd: 'agent-service-oid4vc-get-all-credential-offers' }) // eslint-disable-next-line @typescript-eslint/no-explicit-any async oidcGetAllCredentialOffers(payload: { url: string; orgId: string }): Promise { return this.agentServiceService.oidcGetAllCredentialOffers(payload.url, payload.orgId); } - @MessagePattern({ cmd: 'agent-service-oidc-delete-credential-offer' }) + @MessagePattern({ cmd: 'agent-service-oid4vc-delete-credential-offer' }) // eslint-disable-next-line @typescript-eslint/no-explicit-any async oidcDeleteCredentialOffer(payload: { url: string; orgId: string }): Promise { return this.agentServiceService.oidcDeleteCredentialOffer(payload.url, payload.orgId); diff --git a/apps/agent-service/src/agent-service.service.ts b/apps/agent-service/src/agent-service.service.ts index c2e16bf5b..4f4e877cb 100644 --- a/apps/agent-service/src/agent-service.service.ts +++ b/apps/agent-service/src/agent-service.service.ts @@ -1423,13 +1423,15 @@ export class AgentServiceService { } } - async deleteOidcIssuer(url: string, orgId: string): Promise { + async deleteOidcIssuer(url: string, orgId: string): Promise { try { const getApiKey = await this.getOrgAgentApiKey(orgId); - const data = await this.commonService - .httpDelete(url, { headers: { authorization: getApiKey } }) - .then(async (response) => response); - return data; + const response = await this.commonService.httpDelete(url, { + headers: { authorization: getApiKey } + }); + if (response?.status === 204) { + return 'Data deleted successfully'; + } } catch (error) { this.logger.error(`Error in deleteOidcIssuer in agent service : ${JSON.stringify(error)}`); throw error; diff --git a/apps/api-gateway/src/app.module.ts b/apps/api-gateway/src/app.module.ts index 9994df54d..fe70c182f 100644 --- a/apps/api-gateway/src/app.module.ts +++ b/apps/api-gateway/src/app.module.ts @@ -32,6 +32,7 @@ import { ContextModule } from '@credebl/context/contextModule'; import { LoggerModule } from '@credebl/logger/logger.module'; import { GlobalConfigModule } from '@credebl/config/global-config.module'; import { ConfigModule as PlatformConfig } from '@credebl/config/config.module'; +import { Oid4vcIssuanceModule } from './oid4vc-issuance/oid4vc-issuance.module'; @Module({ imports: [ @@ -64,7 +65,8 @@ import { ConfigModule as PlatformConfig } from '@credebl/config/config.module'; GlobalConfigModule, CacheModule.register({ store: redisStore, host: process.env.REDIS_HOST, port: process.env.REDIS_PORT }), GeoLocationModule, - CloudWalletModule + CloudWalletModule, + Oid4vcIssuanceModule ], controllers: [AppController], providers: [ diff --git a/apps/api-gateway/src/issuance/issuance.controller.ts b/apps/api-gateway/src/issuance/issuance.controller.ts index b94ff1777..65014d79d 100644 --- a/apps/api-gateway/src/issuance/issuance.controller.ts +++ b/apps/api-gateway/src/issuance/issuance.controller.ts @@ -960,7 +960,6 @@ export class IssuanceController { @Res() res: Response ): Promise { issueCredentialDto.type = 'Issuance'; - if (id && 'default' === issueCredentialDto.contextCorrelationId) { issueCredentialDto.orgId = id; } diff --git a/apps/api-gateway/src/issuance/issuance.module.ts b/apps/api-gateway/src/issuance/issuance.module.ts index 5a7df978c..d5f184d16 100644 --- a/apps/api-gateway/src/issuance/issuance.module.ts +++ b/apps/api-gateway/src/issuance/issuance.module.ts @@ -8,7 +8,6 @@ import { getNatsOptions } from '@credebl/common/nats.config'; import { AwsService } from '@credebl/aws'; import { CommonConstants } from '@credebl/common/common.constant'; import { NATSClient } from '@credebl/common/NATSClient'; -import { OidcController } from './oidc.controller'; @Module({ imports: [ @@ -21,7 +20,7 @@ import { OidcController } from './oidc.controller'; } ]) ], - controllers: [IssuanceController, OidcController], + controllers: [IssuanceController], providers: [IssuanceService, CommonService, AwsService, NATSClient] }) export class IssuanceModule {} diff --git a/apps/api-gateway/src/issuance/issuance.service.ts b/apps/api-gateway/src/issuance/issuance.service.ts index 7b620e777..6012e20e2 100644 --- a/apps/api-gateway/src/issuance/issuance.service.ts +++ b/apps/api-gateway/src/issuance/issuance.service.ts @@ -28,15 +28,8 @@ import { IIssuedCredential } from '@credebl/common/interfaces/issuance.interface'; import { IssueCredentialDto } from './dtos/multi-connection.dto'; -import { oidc_issuer, user } from '@prisma/client'; import { NATSClient } from '@credebl/common/NATSClient'; -import { CreateCredentialTemplateDto, UpdateCredentialTemplateDto } from './dtos/oidc-issuer-template.dto'; -import { - CreateOidcCredentialOfferDto, - GetAllCredentialOfferDto, - UpdateCredentialRequestDto -} from './dtos/issuer-sessions.dto'; -import { IssuerCreationDto, IssuerUpdationDto } from './dtos/oidc-issuer.dto'; +import { user } from '@prisma/client'; @Injectable() export class IssuanceService extends BaseService { constructor( @@ -279,114 +272,4 @@ export class IssuanceService extends BaseService { }; return this.natsClient.sendNatsMessage(this.issuanceProxy, 'issued-file-data-and-file-details', payload); } - - // OIDC methods - - async oidcIssuerCreate( - issueCredentialDto: IssuerCreationDto, - orgId: string, - userDetails: user - ): Promise { - const payload = { issueCredentialDto, orgId, userDetails }; - return this.natsClient.sendNatsMessage(this.issuanceProxy, 'oidc-issuer-create', payload); - } - - async oidcIssuerUpdate(issueUpdationDto: IssuerUpdationDto, orgId: string, userDetails: user) { - const payload = { issueUpdationDto, orgId, userDetails }; - return this.natsClient.sendNatsMessage(this.issuanceProxy, 'oidc-issuer-update', payload); - } - - async oidcGetIssuerById(issuerId: string, orgId: string) { - const payload = { issuerId, orgId }; - return this.natsClient.sendNatsMessage(this.issuanceProxy, 'oidc-issuer-get-by-id', payload); - } - - async oidcGetIssuers(orgId: string) { - const payload = { orgId }; - return this.natsClient.sendNatsMessage(this.issuanceProxy, 'oidc-get-issuers-issuance', payload); - } - - async oidcDeleteIssuer(userDetails: user, orgId: string, issuerId: string) { - const payload = { issuerId, orgId, userDetails }; - return this.natsClient.sendNatsMessage(this.issuanceProxy, 'oidc-delete-issuer', payload); - } - - async deleteTemplate(userDetails: user, orgId: string, templateId: string) { - const payload = { templateId, orgId, userDetails }; - return this.natsClient.sendNatsMessage(this.issuanceProxy, 'oidc-template-delete', payload); - } - - async updateTemplate( - userDetails: user, - orgId: string, - templateId: string, - dto: UpdateCredentialTemplateDto, - issuerId: string - ) { - const payload = { templateId, orgId, userDetails, dto, issuerId }; - return this.natsClient.sendNatsMessage(this.issuanceProxy, 'oidc-template-update', payload); - } - - async findByIdTemplate(userDetails: user, orgId: string, templateId: string) { - const payload = { templateId, orgId, userDetails }; - return this.natsClient.sendNatsMessage(this.issuanceProxy, 'oidc-template-find-id', payload); - } - - async findAllTemplate(userDetails: user, orgId: string) { - const payload = { orgId, userDetails }; - return this.natsClient.sendNatsMessage(this.issuanceProxy, 'oidc-template-find-all', payload); - } - - async createTemplate( - CredentialTemplate: CreateCredentialTemplateDto, - userDetails: user, - orgId: string, - issuerId: string - ) { - const payload = { CredentialTemplate, orgId, userDetails, issuerId }; - return this.natsClient.sendNatsMessage(this.issuanceProxy, 'oidc-template-create', payload); - } - - async createOidcCredentialOffer( - oidcCredentialPayload: CreateOidcCredentialOfferDto, - userDetails: user, - orgId: string, - issuerId: string - ) { - const payload = { oidcCredentialPayload, orgId, userDetails, issuerId }; - return this.natsClient.sendNatsMessage(this.issuanceProxy, 'oidc-create-credential-offer', payload); - } - - async updateOidcCredentialOffer( - oidcUpdateCredentialPayload: UpdateCredentialRequestDto, - orgId: string, - issuerId: string - ) { - const payload = { oidcUpdateCredentialPayload, orgId, issuerId }; - return this.natsClient.sendNatsMessage(this.issuanceProxy, 'oidc-update-credential-offer', payload); - } - - async getCredentialOfferDetailsById(offerId: string, orgId: string) { - const payload = { offerId, orgId }; - return this.natsClient.sendNatsMessage(this.issuanceProxy, 'oidc-credential-offer-get-by-id', payload); - } - async getAllCredentialOffers(orgId: string, getAllCredentialOffer: GetAllCredentialOfferDto) { - const payload = { orgId, getAllCredentialOffer }; - return this.natsClient.sendNatsMessage(this.issuanceProxy, 'oidc-credential-offer-get-all', payload); - } - - async deleteCredentialOffers(orgId: string, credentialId: string) { - const payload = { orgId, credentialId }; - return this.natsClient.sendNatsMessage(this.issuanceProxy, 'oidc-credential-offer-delete', payload); - } - - oidcIssueCredentialWebhook( - oidcIssueCredentialDto, - id: string - ): Promise<{ - response: object; - }> { - const payload = { oidcIssueCredentialDto, id }; - return this.natsClient.sendNats(this.issuanceProxy, 'webhook-oidc-issue-credential', payload); - } } diff --git a/apps/api-gateway/src/issuance/dtos/issuer-sessions.dto.ts b/apps/api-gateway/src/oid4vc-issuance/dtos/issuer-sessions.dto.ts similarity index 100% rename from apps/api-gateway/src/issuance/dtos/issuer-sessions.dto.ts rename to apps/api-gateway/src/oid4vc-issuance/dtos/issuer-sessions.dto.ts diff --git a/apps/api-gateway/src/oid4vc-issuance/dtos/oid4vc-credential-wh.dto.ts b/apps/api-gateway/src/oid4vc-issuance/dtos/oid4vc-credential-wh.dto.ts new file mode 100644 index 000000000..d4a02f669 --- /dev/null +++ b/apps/api-gateway/src/oid4vc-issuance/dtos/oid4vc-credential-wh.dto.ts @@ -0,0 +1,50 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsArray, IsObject, IsString } from 'class-validator'; + +export class CredentialOfferPayloadDto { + @ApiProperty() + @IsString() + // eslint-disable-next-line camelcase + credential_issuer!: string; + + @ApiProperty({ type: [String] }) + @IsArray() + // eslint-disable-next-line camelcase + credential_configuration_ids!: string[]; + + @ApiProperty({ type: 'object', additionalProperties: true }) + @IsObject() + grants!: Record; + + @ApiProperty({ type: [Object] }) + @IsArray() + credentials!: Record[]; +} + +export class IssuanceMetadataDto { + @ApiProperty() + @IsString() + issuerDid!: string; + + @ApiProperty({ type: [Object] }) + @IsArray() + credentials!: Record[]; +} + +export class OidcIssueCredentialDto { + @ApiProperty() + @IsString() + id!: string; + + @ApiProperty() + @IsString() + credentialOfferId!: string; + + @ApiProperty() + @IsString() + state!: string; + + @ApiProperty() + @IsString() + contextCorrelationId!: string; +} diff --git a/apps/api-gateway/src/issuance/dtos/oidc-issuer-template.dto.ts b/apps/api-gateway/src/oid4vc-issuance/dtos/oid4vc-issuer-template.dto.ts similarity index 98% rename from apps/api-gateway/src/issuance/dtos/oidc-issuer-template.dto.ts rename to apps/api-gateway/src/oid4vc-issuance/dtos/oid4vc-issuer-template.dto.ts index 3fd6bc3c2..96332d729 100644 --- a/apps/api-gateway/src/issuance/dtos/oidc-issuer-template.dto.ts +++ b/apps/api-gateway/src/oid4vc-issuance/dtos/oid4vc-issuer-template.dto.ts @@ -11,7 +11,7 @@ import { } from 'class-validator'; import { ApiExtraModels, ApiProperty, ApiPropertyOptional, getSchemaPath, PartialType } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { DisplayDto } from './oidc-issuer.dto'; +import { DisplayDto } from './oid4vc-issuer.dto'; export class CredentialAttributeDto { @ApiProperty({ required: false, description: 'Whether the attribute is mandatory' }) diff --git a/apps/api-gateway/src/issuance/dtos/oidc-issuer.dto.ts b/apps/api-gateway/src/oid4vc-issuance/dtos/oid4vc-issuer.dto.ts similarity index 95% rename from apps/api-gateway/src/issuance/dtos/oidc-issuer.dto.ts rename to apps/api-gateway/src/oid4vc-issuance/dtos/oid4vc-issuer.dto.ts index ce54603fa..b9e67f423 100644 --- a/apps/api-gateway/src/issuance/dtos/oidc-issuer.dto.ts +++ b/apps/api-gateway/src/oid4vc-issuance/dtos/oid4vc-issuer.dto.ts @@ -199,7 +199,7 @@ export class ClientAuthenticationDto { export class AuthorizationServerConfigDto { @ApiProperty({ description: 'Authorization server issuer URL', - example: 'https://example-oidc-provider.com' + example: 'https://example-oid4vc-provider.com' }) @IsString() issuer: string; @@ -257,15 +257,6 @@ export class IssuerCreationDto { export class IssuerUpdationDto { issuerId?: string; - @ApiProperty({ - description: 'accessTokenSignerKeyType', - example: 'ed25519' - }) - @IsString({ - message: 'accessTokenSignerKeyType from IssuerCreationDto -> accessTokenSignerKeyType, must be a string' - }) - accessTokenSignerKeyType: string; - @ApiProperty({ description: 'Localized display information for the credential', type: [DisplayDto] diff --git a/apps/api-gateway/src/issuance/oidc.controller.ts b/apps/api-gateway/src/oid4vc-issuance/oid4vc-issuance.controller.ts similarity index 68% rename from apps/api-gateway/src/issuance/oidc.controller.ts rename to apps/api-gateway/src/oid4vc-issuance/oid4vc-issuance.controller.ts index 82bf3d1f7..a0212478f 100644 --- a/apps/api-gateway/src/issuance/oidc.controller.ts +++ b/apps/api-gateway/src/oid4vc-issuance/oid4vc-issuance.controller.ts @@ -35,7 +35,6 @@ import { UnauthorizedErrorDto } from '../dtos/unauthorized-error.dto'; import { ForbiddenErrorDto } from '../dtos/forbidden-error.dto'; import { Response } from 'express'; import IResponseType, { IResponse } from '@credebl/common/interfaces/response.interface'; -import { IssuanceService } from './issuance.service'; import { User } from '../authz/decorators/user.decorator'; import { ResponseMessages } from '@credebl/common/response-messages'; import { Roles } from '../authz/decorators/roles.decorator'; @@ -44,8 +43,10 @@ import { OrgRolesGuard } from '../authz/guards/org-roles.guard'; import { CustomExceptionFilter } from 'apps/api-gateway/common/exception-handler'; /* eslint-disable @typescript-eslint/no-unused-vars */ import { user } from '@prisma/client'; -import { IssuerCreationDto, IssuerUpdationDto } from './dtos/oidc-issuer.dto'; -import { CreateCredentialTemplateDto, UpdateCredentialTemplateDto } from './dtos/oidc-issuer-template.dto'; +import { IssuerCreationDto, IssuerUpdationDto } from './dtos/oid4vc-issuer.dto'; +import { CreateCredentialTemplateDto, UpdateCredentialTemplateDto } from './dtos/oid4vc-issuer-template.dto'; +import { OidcIssueCredentialDto } from './dtos/oid4vc-credential-wh.dto'; +import { Oid4vcIssuanceService } from './oid4vc-issuance.service'; import { CreateOidcCredentialOfferDto, GetAllCredentialOfferDto, @@ -53,11 +54,11 @@ import { } from './dtos/issuer-sessions.dto'; @Controller() @UseFilters(CustomExceptionFilter) -@ApiTags('OIDC') +@ApiTags('OID4VC') @ApiUnauthorizedResponse({ status: HttpStatus.UNAUTHORIZED, description: 'Unauthorized', type: UnauthorizedErrorDto }) @ApiForbiddenResponse({ status: HttpStatus.FORBIDDEN, description: 'Forbidden', type: ForbiddenErrorDto }) -export class OidcController { - constructor(private readonly issueCredentialService: IssuanceService) {} +export class Oid4vcIssuanceController { + constructor(private readonly oid4vcIssuanceService: Oid4vcIssuanceService) {} /** * Create issuer against a org(tenant) * @param orgId The ID of the organization @@ -66,9 +67,12 @@ export class OidcController { * @returns The status of the deletion operation */ - @Post('/orgs/:orgId/oidc/issuers') - @ApiOperation({ summary: 'Create OIDC issuer', description: 'Create OIDC issuer by orgId' }) - @ApiResponse({ status: HttpStatus.OK, description: 'Success', type: ApiResponseDto }) + @Post('/orgs/:orgId/oid4vc/issuers') + @ApiOperation({ + summary: 'Create OID4VC issuer', + description: 'Creates a new OID4VC issuer for the specified organization.' + }) + @ApiResponse({ status: HttpStatus.OK, description: 'Issuer created successfully.', type: ApiResponseDto }) @ApiBearerAuth() @Roles(OrgRoles.OWNER) @UseGuards(AuthGuard('jwt'), OrgRolesGuard) @@ -86,7 +90,7 @@ export class OidcController { @Body() issueCredentialDto: IssuerCreationDto, @Res() res: Response ): Promise { - const createIssuer = await this.issueCredentialService.oidcIssuerCreate(issueCredentialDto, orgId, user); + const createIssuer = await this.oid4vcIssuanceService.oidcIssuerCreate(issueCredentialDto, orgId, user); const finalResponse: IResponse = { statusCode: HttpStatus.CREATED, message: ResponseMessages.oidcIssuer.success.issuerConfig, @@ -95,9 +99,12 @@ export class OidcController { return res.status(HttpStatus.CREATED).json(finalResponse); } - @Post('/orgs/:orgId/oidc/issuers/:issuerId') - @ApiOperation({ summary: 'Update OIDC issuer', description: 'Update OIDC issuer by orgId' }) - @ApiResponse({ status: HttpStatus.OK, description: 'Success', type: ApiResponseDto }) + @Post('/orgs/:orgId/oid4vc/issuers/:issuerId') + @ApiOperation({ + summary: 'Update OID4VC issuer', + description: 'Updates an existing OID4VC issuer for the specified organization.' + }) + @ApiResponse({ status: HttpStatus.OK, description: 'Issuer updated successfully.', type: ApiResponseDto }) @ApiBearerAuth() @Roles(OrgRoles.OWNER) @UseGuards(AuthGuard('jwt'), OrgRolesGuard) @@ -118,7 +125,7 @@ export class OidcController { @Res() res: Response ): Promise { issueCredentialDto.issuerId = issuerId; - const createIssuer = await this.issueCredentialService.oidcIssuerUpdate(issueCredentialDto, orgId, user); + const createIssuer = await this.oid4vcIssuanceService.oidcIssuerUpdate(issueCredentialDto, orgId, user); const finalResponse: IResponse = { statusCode: HttpStatus.CREATED, message: ResponseMessages.oidcIssuer.success.issuerConfigUpdate, @@ -127,9 +134,9 @@ export class OidcController { return res.status(HttpStatus.CREATED).json(finalResponse); } - @Get('/orgs/:orgId/oidc/issuers/:issuerId') - @ApiOperation({ summary: 'Get OIDC issuer', description: 'Get OIDC issuer by issuerId' }) - @ApiResponse({ status: HttpStatus.OK, description: 'Success', type: ApiResponseDto }) + @Get('/orgs/:orgId/oid4vc/issuers/:issuerId') + @ApiOperation({ summary: 'Get OID4VC issuer', description: 'Retrieves an OID4VC issuer by issuerId.' }) + @ApiResponse({ status: HttpStatus.OK, description: 'Issuer fetched successfully.', type: ApiResponseDto }) @ApiBearerAuth() @Roles(OrgRoles.OWNER) @UseGuards(AuthGuard('jwt'), OrgRolesGuard) @@ -147,7 +154,7 @@ export class OidcController { issuerId: string, @Res() res: Response ): Promise { - const oidcIssuer = await this.issueCredentialService.oidcGetIssuerById(issuerId, orgId); + const oidcIssuer = await this.oid4vcIssuanceService.oidcGetIssuerById(issuerId, orgId); const finalResponse: IResponse = { statusCode: HttpStatus.CREATED, message: ResponseMessages.oidcIssuer.success.fetch, @@ -156,9 +163,12 @@ export class OidcController { return res.status(HttpStatus.CREATED).json(finalResponse); } - @Get('/orgs/:orgId/oidc/issuers') - @ApiOperation({ summary: 'Get OIDC issuer', description: 'Get OIDC issuer by orgId' }) - @ApiResponse({ status: HttpStatus.OK, description: 'Success', type: ApiResponseDto }) + @Get('/orgs/:orgId/oid4vc/issuers') + @ApiOperation({ + summary: 'Get OID4VC issuers', + description: 'Retrieves all OID4VC issuers for the specified organization.' + }) + @ApiResponse({ status: HttpStatus.OK, description: 'Issuers fetched successfully.', type: ApiResponseDto }) @ApiBearerAuth() @Roles(OrgRoles.OWNER) @UseGuards(AuthGuard('jwt'), OrgRolesGuard) @@ -174,7 +184,7 @@ export class OidcController { orgId: string, @Res() res: Response ): Promise { - const oidcIssuer = await this.issueCredentialService.oidcGetIssuers(orgId); + const oidcIssuer = await this.oid4vcIssuanceService.oidcGetIssuers(orgId); const finalResponse: IResponse = { statusCode: HttpStatus.OK, message: ResponseMessages.oidcIssuer.success.fetch, @@ -183,11 +193,14 @@ export class OidcController { return res.status(HttpStatus.OK).json(finalResponse); } - @Delete('/orgs/:orgId/oidc/:issuerId') + @Delete('/orgs/:orgId/oid4vc/:issuerId') + @ApiOperation({ + summary: 'Delete OID4VC issuer', + description: 'Deletes an OID4VC issuer for the specified organization.' + }) @ApiBearerAuth() @Roles(OrgRoles.OWNER) @UseGuards(AuthGuard('jwt'), OrgRolesGuard) - @ApiOperation({ summary: 'Delete oidc issuer' }) async deleteOidcIssuer( @Param( 'orgId', @@ -210,7 +223,7 @@ export class OidcController { @User() user: user, @Res() res: Response ): Promise { - await this.issueCredentialService.oidcDeleteIssuer(user, orgId, issuerId); + await this.oid4vcIssuanceService.oidcDeleteIssuer(user, orgId, issuerId); const finalResponse: IResponse = { statusCode: HttpStatus.OK, @@ -220,9 +233,12 @@ export class OidcController { return res.status(HttpStatus.OK).json(finalResponse); } - @Post('/orgs/:orgId/oidc/:issuerId/template') - @ApiOperation({ summary: 'Create credential template' }) - @ApiResponse({ status: HttpStatus.CREATED, description: 'Template created successfully' }) + @Post('/orgs/:orgId/oid4vc/:issuerId/template') + @ApiOperation({ + summary: 'Create credential template', + description: 'Creates a new credential template for the specified issuer.' + }) + @ApiResponse({ status: HttpStatus.CREATED, description: 'Template created successfully.' }) @ApiBearerAuth() @Roles(OrgRoles.OWNER) @UseGuards(AuthGuard('jwt'), OrgRolesGuard) @@ -237,7 +253,7 @@ export class OidcController { ): Promise { CredentialTemplate.issuerId = issuerId; console.log('THis is dto', JSON.stringify(CredentialTemplate, null, 2)); - const template = await this.issueCredentialService.createTemplate(CredentialTemplate, user, orgId, issuerId); + const template = await this.oid4vcIssuanceService.createTemplate(CredentialTemplate, user, orgId, issuerId); const finalResponse: IResponse = { statusCode: HttpStatus.CREATED, @@ -248,9 +264,12 @@ export class OidcController { return res.status(HttpStatus.CREATED).json(finalResponse); } - @Get('/orgs/:orgId/oidc/:issuerId/template') - @ApiOperation({ summary: 'List credential templates' }) - @ApiResponse({ status: HttpStatus.OK, description: 'List of templates' }) + @Get('/orgs/:orgId/oid4vc/:issuerId/template') + @ApiOperation({ + summary: 'List credential templates', + description: 'Lists all credential templates for the specified issuer.' + }) + @ApiResponse({ status: HttpStatus.OK, description: 'List of templates.' }) @ApiBearerAuth() @Roles(OrgRoles.OWNER) @UseGuards(AuthGuard('jwt'), OrgRolesGuard) @@ -268,15 +287,14 @@ export class OidcController { 'issuerId', new ParseUUIDPipe({ exceptionFactory: (): Error => { - throw new BadRequestException(ResponseMessages.organisation.error.invalidOrgId); + throw new BadRequestException(ResponseMessages.oidcTemplate.error.invalidId); } }) ) issuerId: string, - @User() user: user, @Res() res: Response ): Promise { - const templates = await this.issueCredentialService.findAllTemplate(user, orgId, issuerId); + const templates = await this.oid4vcIssuanceService.findAllTemplate(orgId, issuerId); const finalResponse: IResponse = { statusCode: HttpStatus.OK, @@ -287,8 +305,8 @@ export class OidcController { return res.status(HttpStatus.OK).json(finalResponse); } - @Get('/orgs/:orgId/oidc/:issuerId/template/:templateId') - @ApiOperation({ summary: 'Get credential template by ID' }) + @Get('/orgs/:orgId/oid4vc/:issuerId/template/:templateId') + @ApiOperation({ summary: 'Get credential template by ID', description: 'Retrieves a credential template by its ID.' }) @ApiBearerAuth() @Roles(OrgRoles.OWNER) @UseGuards(AuthGuard('jwt'), OrgRolesGuard) @@ -302,43 +320,29 @@ export class OidcController { }) ) orgId: string, - @Param( - 'issuerId', - new ParseUUIDPipe({ - exceptionFactory: (): Error => { - throw new BadRequestException(ResponseMessages.organisation.error.invalidOrgId); - } - }) - ) - issuerId: string, - @Param( - 'templateId', - new ParseUUIDPipe({ - exceptionFactory: (): Error => { - throw new BadRequestException(ResponseMessages.organisation.error.invalidOrgId); - } - }) - ) + @Param('templateId') templateId: string, - @User() user: user, @Res() res: Response ): Promise { - const template = await this.issueCredentialService.findByIdTemplate(user, orgId, templateId); + const template = await this.oid4vcIssuanceService.findByIdTemplate(orgId, templateId); const finalResponse: IResponse = { statusCode: HttpStatus.OK, - message: ResponseMessages.oidcTemplate.success.fetch, + message: ResponseMessages.oidcTemplate.success.getById, data: template }; return res.status(HttpStatus.OK).json(finalResponse); } - @Patch('/orgs/:orgId/oidc/:issuerId/template/:templateId') + @Patch('/orgs/:orgId/oid4vc/:issuerId/template/:templateId') + @ApiOperation({ + summary: 'Update credential template', + description: 'Updates a credential template for the specified issuer.' + }) @ApiBearerAuth() @Roles(OrgRoles.OWNER) @UseGuards(AuthGuard('jwt'), OrgRolesGuard) - @ApiOperation({ summary: 'Update credential template' }) async updateTemplate( @Param( 'orgId', @@ -371,7 +375,7 @@ export class OidcController { @Body() dto: UpdateCredentialTemplateDto, @Res() res: Response ): Promise { - const updated = await this.issueCredentialService.updateTemplate(user, orgId, templateId, dto, issuerId); + const updated = await this.oid4vcIssuanceService.updateTemplate(user, orgId, templateId, dto, issuerId); const finalResponse: IResponse = { statusCode: HttpStatus.OK, @@ -382,11 +386,14 @@ export class OidcController { return res.status(HttpStatus.OK).json(finalResponse); } - @Delete('/orgs/:orgId/oidc/:issuerId/template/:templateId') + @Delete('/orgs/:orgId/oid4vc/:issuerId/template/:templateId') + @ApiOperation({ + summary: 'Delete credential template', + description: 'Deletes a credential template for the specified issuer.' + }) @ApiBearerAuth() @Roles(OrgRoles.OWNER) @UseGuards(AuthGuard('jwt'), OrgRolesGuard) - @ApiOperation({ summary: 'Delete credential template' }) async deleteTemplate( @Param( 'orgId', @@ -418,7 +425,7 @@ export class OidcController { @User() user: user, @Res() res: Response ): Promise { - await this.issueCredentialService.deleteTemplate(user, orgId, templateId); + await this.oid4vcIssuanceService.deleteTemplate(user, orgId, templateId); const finalResponse: IResponse = { statusCode: HttpStatus.OK, message: ResponseMessages.oidcTemplate.success.delete @@ -427,15 +434,15 @@ export class OidcController { return res.status(HttpStatus.OK).json(finalResponse); } - @Post('/orgs/:orgId/oidc/:issuerId/create-offer') - @ApiOperation({ summary: 'Create OIDC Credential Offer' }) - @ApiResponse({ - status: HttpStatus.CREATED, - description: `This endpoint creates a new OIDC4VCI credential-offer for a given issuer. It allows clients to request issuance of credentials (e.g., Birth Certificate, Driving License, Student ID) from a registered OIDC issuer using the issuer's ID.` + @Post('/orgs/:orgId/oid4vc/:issuerId/create-offer') + @ApiOperation({ + summary: 'Create OID4VC Credential Offer', + description: 'Creates a new OIDC4VCI credential-offer for a given issuer.' }) - // @ApiBearerAuth() - // @Roles(OrgRoles.OWNER) - // @UseGuards(AuthGuard('jwt'), OrgRolesGuard) + @ApiResponse({ status: HttpStatus.CREATED, description: 'Credential offer created successfully.' }) + @ApiBearerAuth() + @Roles(OrgRoles.OWNER) + @UseGuards(AuthGuard('jwt'), OrgRolesGuard) async createOidcCredentialOffer( @Param('orgId') orgId: string, @@ -446,8 +453,7 @@ export class OidcController { @Res() res: Response ): Promise { oidcCredentialPayload.issuerId = issuerId; - console.log('This is dto', JSON.stringify(oidcCredentialPayload, null, 2)); - const template = await this.issueCredentialService.createOidcCredentialOffer( + const template = await this.oid4vcIssuanceService.createOidcCredentialOffer( oidcCredentialPayload, user, orgId, @@ -462,8 +468,12 @@ export class OidcController { return res.status(HttpStatus.CREATED).json(finalResponse); } - @Put('/orgs/:orgId/oidc/:issuerId/:credentialId/update-offer') - @ApiResponse({ status: HttpStatus.OK, description: 'Success', type: ApiResponseDto }) + @Put('/orgs/:orgId/oid4vc/:issuerId/:credentialId/update-offer') + @ApiOperation({ + summary: 'Update OID4VC Credential Offer', + description: 'Updates an existing OIDC4VCI credential-offer.' + }) + @ApiResponse({ status: HttpStatus.OK, description: 'Credential offer updated successfully.', type: ApiResponseDto }) @ApiBearerAuth() @Roles(OrgRoles.OWNER) @UseGuards(AuthGuard('jwt'), OrgRolesGuard) @@ -476,7 +486,7 @@ export class OidcController { ): Promise { oidcUpdateCredentialPayload.issuerId = issuerId; oidcUpdateCredentialPayload.credentialOfferId = credentialId; - const updateCredentialOffer = await this.issueCredentialService.updateOidcCredentialOffer( + const updateCredentialOffer = await this.oid4vcIssuanceService.updateOidcCredentialOffer( oidcUpdateCredentialPayload, orgId, issuerId @@ -490,9 +500,12 @@ export class OidcController { return res.status(HttpStatus.OK).json(finalResponse); } - @Get('/orgs/:orgId/oidc/credential-offer/:id') - @ApiOperation({ summary: 'Get OIDC credential offer', description: 'Get OIDC credential offer by id' }) - @ApiResponse({ status: HttpStatus.OK, description: 'Success', type: ApiResponseDto }) + @Get('/orgs/:orgId/oid4vc/credential-offer/:id') + @ApiOperation({ + summary: 'Get OID4VC credential offer', + description: 'Retrieves an OID4VC credential offer by its ID.' + }) + @ApiResponse({ status: HttpStatus.OK, description: 'Credential offer fetched successfully.', type: ApiResponseDto }) @ApiBearerAuth() @Roles(OrgRoles.OWNER) @UseGuards(AuthGuard('jwt'), OrgRolesGuard) @@ -510,7 +523,7 @@ export class OidcController { id: string, @Res() res: Response ): Promise { - const oidcIssuer = await this.issueCredentialService.getCredentialOfferDetailsById(id, orgId); + const oidcIssuer = await this.oid4vcIssuanceService.getCredentialOfferDetailsById(id, orgId); const finalResponse: IResponse = { statusCode: HttpStatus.OK, message: ResponseMessages.oidcIssuerSession.success.getById, @@ -519,8 +532,16 @@ export class OidcController { return res.status(HttpStatus.OK).json(finalResponse); } - @Get('/orgs/:orgId/oidc/credential-offer') - @ApiResponse({ status: HttpStatus.OK, description: 'Success', type: ApiResponseDto }) + @Get('/orgs/:orgId/oid4vc/credential-offer') + @ApiOperation({ + summary: 'Get all OID4VC credential offers', + description: 'Retrieves all OID4VC credential offers for the specified organization.' + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'All credential offers fetched successfully.', + type: ApiResponseDto + }) @ApiBearerAuth() @Roles(OrgRoles.OWNER) @UseGuards(AuthGuard('jwt'), OrgRolesGuard) @@ -529,7 +550,7 @@ export class OidcController { @Param('orgId') orgId: string, @Res() res: Response ): Promise { - const connectionDetails = await this.issueCredentialService.getAllCredentialOffers(orgId, getAllCredentialOffer); + const connectionDetails = await this.oid4vcIssuanceService.getAllCredentialOffers(orgId, getAllCredentialOffer); const finalResponse: IResponse = { statusCode: HttpStatus.OK, @@ -539,8 +560,12 @@ export class OidcController { return res.status(HttpStatus.OK).json(finalResponse); } - @Delete('/orgs/:orgId/oidc/:credentialId/delete-offer') - @ApiResponse({ status: HttpStatus.OK, description: 'Success', type: ApiResponseDto }) + @Delete('/orgs/:orgId/oid4vc/:credentialId/delete-offer') + @ApiOperation({ + summary: 'Delete OID4VC credential offer', + description: 'Deletes an OID4VC credential offer for the specified organization.' + }) + @ApiResponse({ status: HttpStatus.OK, description: 'Credential offer deleted successfully.', type: ApiResponseDto }) @ApiBearerAuth() @Roles(OrgRoles.OWNER) @UseGuards(AuthGuard('jwt'), OrgRolesGuard) @@ -549,7 +574,7 @@ export class OidcController { @Param('credentialId') credentialId: string, @Res() res: Response ): Promise { - const deletedofferDetails = await this.issueCredentialService.deleteCredentialOffers(orgId, credentialId); + const deletedofferDetails = await this.oid4vcIssuanceService.deleteCredentialOffers(orgId, credentialId); const finalResponse: IResponse = { statusCode: HttpStatus.NO_CONTENT, @@ -561,23 +586,24 @@ export class OidcController { /** * Catch issue credential webhook responses - * @param oidcIssueCredentialDto The details of the oidc issued credential + * @param oidcIssueCredentialDto The details of the oid4vc issued credential * @param id The ID of the organization * @param res The response object - * @returns The details of the oidc issued credential + * @returns The details of the oid4vc issued credential */ - @Post('wh/:id/credentials') + @Post('wh/:id/openid4vc-issuance') @ApiExcludeEndpoint() @ApiOperation({ - summary: 'Catch OIDC credential states', - description: 'Catch OIDC credential states' + summary: 'Catch OID4VC credential states', + description: 'Handles webhook responses for OID4VC credential issuance.' }) async getIssueCredentialWebhook( - @Body() oidcIssueCredentialDto, + @Body() oidcIssueCredentialDto: OidcIssueCredentialDto, @Param('id') id: string, @Res() res: Response ): Promise { - const getCredentialDetails = await this.issueCredentialService.oidcIssueCredentialWebhook( + console.log('Webhook received:', JSON.stringify(oidcIssueCredentialDto, null, 2)); + const getCredentialDetails = await this.oid4vcIssuanceService.oidcIssueCredentialWebhook( oidcIssueCredentialDto, id ); diff --git a/apps/api-gateway/src/oid4vc-issuance/oid4vc-issuance.module.ts b/apps/api-gateway/src/oid4vc-issuance/oid4vc-issuance.module.ts new file mode 100644 index 000000000..61470bce8 --- /dev/null +++ b/apps/api-gateway/src/oid4vc-issuance/oid4vc-issuance.module.ts @@ -0,0 +1,24 @@ +import { Module } from '@nestjs/common'; +import { Oid4vcIssuanceService } from './oid4vc-issuance.service'; +import { Oid4vcIssuanceController } from './oid4vc-issuance.controller'; +import { NATSClient } from '@credebl/common/NATSClient'; +import { getNatsOptions } from '@credebl/common/nats.config'; +import { CommonConstants } from '@credebl/common/common.constant'; +import { ClientsModule, Transport } from '@nestjs/microservices'; +import { HttpModule } from '@nestjs/axios'; + +@Module({ + imports: [ + HttpModule, + ClientsModule.register([ + { + name: 'NATS_CLIENT', + transport: Transport.NATS, + options: getNatsOptions(CommonConstants.ISSUANCE_SERVICE, process.env.API_GATEWAY_NKEY_SEED) + } + ]) + ], + controllers: [Oid4vcIssuanceController], + providers: [Oid4vcIssuanceService, NATSClient] +}) +export class Oid4vcIssuanceModule {} diff --git a/apps/api-gateway/src/oid4vc-issuance/oid4vc-issuance.service.ts b/apps/api-gateway/src/oid4vc-issuance/oid4vc-issuance.service.ts new file mode 100644 index 000000000..de051536c --- /dev/null +++ b/apps/api-gateway/src/oid4vc-issuance/oid4vc-issuance.service.ts @@ -0,0 +1,133 @@ +import { NATSClient } from '@credebl/common/NATSClient'; +import { Inject, Injectable } from '@nestjs/common'; +import { ClientProxy } from '@nestjs/microservices'; +import { IssuerCreationDto, IssuerUpdationDto } from './dtos/oid4vc-issuer.dto'; +import { BaseService } from 'libs/service/base.service'; +// eslint-disable-next-line camelcase +import { oidc_issuer, user } from '@prisma/client'; +import { CreateCredentialTemplateDto, UpdateCredentialTemplateDto } from './dtos/oid4vc-issuer-template.dto'; +import { + CreateOidcCredentialOfferDto, + GetAllCredentialOfferDto, + UpdateCredentialRequestDto +} from './dtos/issuer-sessions.dto'; +import { OidcIssueCredentialDto } from './dtos/oid4vc-credential-wh.dto'; + +@Injectable() +export class Oid4vcIssuanceService extends BaseService { + constructor( + @Inject('NATS_CLIENT') private readonly issuanceProxy: ClientProxy, + private readonly natsClient: NATSClient + ) { + super('Oid4vcIssuanceService'); + } + + async oidcIssuerCreate( + issueCredentialDto: IssuerCreationDto, + orgId: string, + userDetails: user + // eslint-disable-next-line camelcase + ): Promise { + const payload = { issueCredentialDto, orgId, userDetails }; + return this.natsClient.sendNatsMessage(this.issuanceProxy, 'oid4vc-issuer-create', payload); + } + + async oidcIssuerUpdate(issueUpdationDto: IssuerUpdationDto, orgId: string, userDetails: user): Promise { + const payload = { issueUpdationDto, orgId, userDetails }; + return this.natsClient.sendNatsMessage(this.issuanceProxy, 'oid4vc-issuer-update', payload); + } + + async oidcGetIssuerById(issuerId: string, orgId: string): Promise { + const payload = { issuerId, orgId }; + return this.natsClient.sendNatsMessage(this.issuanceProxy, 'oid4vc-issuer-get-by-id', payload); + } + + async oidcGetIssuers(orgId: string): Promise { + const payload = { orgId }; + return this.natsClient.sendNatsMessage(this.issuanceProxy, 'oid4vc-get-issuers-issuance', payload); + } + + async oidcDeleteIssuer(userDetails: user, orgId: string, issuerId: string): Promise { + const payload = { issuerId, orgId, userDetails }; + return this.natsClient.sendNatsMessage(this.issuanceProxy, 'oid4vc-delete-issuer', payload); + } + + async deleteTemplate(userDetails: user, orgId: string, templateId: string): Promise { + const payload = { templateId, orgId, userDetails }; + return this.natsClient.sendNatsMessage(this.issuanceProxy, 'oid4vc-template-delete', payload); + } + + async updateTemplate( + userDetails: user, + orgId: string, + templateId: string, + dto: UpdateCredentialTemplateDto, + issuerId: string + ): Promise { + const payload = { templateId, orgId, userDetails, dto, issuerId }; + return this.natsClient.sendNatsMessage(this.issuanceProxy, 'oid4vc-template-update', payload); + } + + async findByIdTemplate(orgId: string, templateId: string): Promise { + const payload = { templateId, orgId }; + return this.natsClient.sendNatsMessage(this.issuanceProxy, 'oid4vc-template-find-id', payload); + } + + async findAllTemplate(orgId: string, issuerId: string): Promise { + const payload = { orgId, issuerId }; + return this.natsClient.sendNatsMessage(this.issuanceProxy, 'oid4vc-template-find-all', payload); + } + + async createTemplate( + CredentialTemplate: CreateCredentialTemplateDto, + userDetails: user, + orgId: string, + issuerId: string + ): Promise { + const payload = { CredentialTemplate, orgId, userDetails, issuerId }; + return this.natsClient.sendNatsMessage(this.issuanceProxy, 'oid4vc-template-create', payload); + } + + async createOidcCredentialOffer( + oidcCredentialPayload: CreateOidcCredentialOfferDto, + userDetails: user, + orgId: string, + issuerId: string + ): Promise { + const payload = { oidcCredentialPayload, orgId, userDetails, issuerId }; + return this.natsClient.sendNatsMessage(this.issuanceProxy, 'oid4vc-create-credential-offer', payload); + } + + async updateOidcCredentialOffer( + oidcUpdateCredentialPayload: UpdateCredentialRequestDto, + orgId: string, + issuerId: string + ): Promise { + const payload = { oidcUpdateCredentialPayload, orgId, issuerId }; + return this.natsClient.sendNatsMessage(this.issuanceProxy, 'oid4vc-update-credential-offer', payload); + } + + async getCredentialOfferDetailsById(offerId: string, orgId: string): Promise { + const payload = { offerId, orgId }; + return this.natsClient.sendNatsMessage(this.issuanceProxy, 'oid4vc-credential-offer-get-by-id', payload); + } + async getAllCredentialOffers(orgId: string, getAllCredentialOffer: GetAllCredentialOfferDto): Promise { + const payload = { orgId, getAllCredentialOffer }; + return this.natsClient.sendNatsMessage(this.issuanceProxy, 'oid4vc-credential-offer-get-all', payload); + } + + async deleteCredentialOffers(orgId: string, credentialId: string): Promise { + const payload = { orgId, credentialId }; + return this.natsClient.sendNatsMessage(this.issuanceProxy, 'oid4vc-credential-offer-delete', payload); + } + + oidcIssueCredentialWebhook( + oidcIssueCredentialDto: OidcIssueCredentialDto, + id: string + ): Promise<{ + response: object; + }> { + const payload = { oidcIssueCredentialDto, id }; + return this.natsClient.sendNats(this.issuanceProxy, 'webhook-oid4vc-issue-credential', payload); + } +} diff --git a/apps/issuance/constant/issuance.ts b/apps/issuance/constant/issuance.ts index a39a160ca..d32498997 100644 --- a/apps/issuance/constant/issuance.ts +++ b/apps/issuance/constant/issuance.ts @@ -1,4 +1,4 @@ -import { AccessTokenSignerKeyType } from '../interfaces/oidc-issuance.interfaces'; +import { AccessTokenSignerKeyType } from '../interfaces/oid4vc-issuance.interfaces'; export const dpopSigningAlgValuesSupported = ['RS256', 'ES256', 'EdDSA']; export const credentialConfigurationsSupported = {}; diff --git a/apps/issuance/libs/helpers/credential-sessions.builder.ts b/apps/issuance/libs/helpers/credential-sessions.builder.ts index cecd07ab6..5fb4997b5 100644 --- a/apps/issuance/libs/helpers/credential-sessions.builder.ts +++ b/apps/issuance/libs/helpers/credential-sessions.builder.ts @@ -1,7 +1,7 @@ // builder/credential-offer.builder.ts /* eslint-disable @typescript-eslint/naming-convention, @typescript-eslint/explicit-function-return-type, @typescript-eslint/explicit-module-boundary-types, camelcase */ import { Prisma, credential_templates } from '@prisma/client'; -import { GetAllCredentialOffer } from 'apps/issuance/interfaces/oidc-issuer-sessions.interfaces'; +import { GetAllCredentialOffer } from 'apps/issuance/interfaces/oid4vc-issuer-sessions.interfaces'; /* ============================================================================ Domain Types @@ -214,7 +214,7 @@ function buildOneCredential( } /** - * Build the full OIDC credential offer payload. + * Build the full OID4VC credential offer payload. * - Verifies template IDs * - Validates mandatory claims per template * - Normalizes formats & IDs diff --git a/apps/issuance/libs/helpers/issuer.metadata.ts b/apps/issuance/libs/helpers/issuer.metadata.ts index f9950f807..76d183052 100644 --- a/apps/issuance/libs/helpers/issuer.metadata.ts +++ b/apps/issuance/libs/helpers/issuer.metadata.ts @@ -1,7 +1,7 @@ /* eslint-disable camelcase */ import { oidc_issuer, Prisma } from '@prisma/client'; import { batchCredentialIssuanceDefault } from '../../constant/issuance'; -import { CreateOidcCredentialOffer } from 'apps/issuance/interfaces/oidc-issuer-sessions.interfaces'; +import { CreateOidcCredentialOffer } from 'apps/issuance/interfaces/oid4vc-issuer-sessions.interfaces'; type AttributeDisplay = { name: string; locale: string }; type AttributeDef = { @@ -206,7 +206,7 @@ function isDisplayArray(x: unknown): x is DisplayItem[] { * Build issuer metadata payload from issuer row + credential configurations. * * @param credentialConfigurations Object with credentialConfigurationsSupported (from your template builder) - * @param oidcIssuer OIDC issuer row (uses publicIssuerId and metadata -> display) + * @param oidcIssuer OID4VC issuer row (uses publicIssuerId and metadata -> display) * @param opts Optional overrides: dpopAlgs[], accessTokenSignerKeyType */ // eslint-disable-next-line @typescript-eslint/explicit-function-return-type diff --git a/apps/issuance/src/issuance.controller.ts b/apps/issuance/src/issuance.controller.ts index b5c290dbf..0f1339cd6 100644 --- a/apps/issuance/src/issuance.controller.ts +++ b/apps/issuance/src/issuance.controller.ts @@ -21,22 +21,11 @@ import { Controller } from '@nestjs/common'; import { IssuanceService } from './issuance.service'; import { MessagePattern } from '@nestjs/microservices'; import { OOBIssueCredentialDto } from 'apps/api-gateway/src/issuance/dtos/issuance.dto'; -import { credential_templates, oidc_issuer, user } from '@prisma/client'; -import { CreateCredentialTemplate, UpdateCredentialTemplate } from '../interfaces/oidc-template.interface'; -import { IssuerCreation, IssuerUpdation } from '../interfaces/oidc-issuance.interfaces'; -import { - CreateOidcCredentialOffer, - GetAllCredentialOffer, - UpdateCredentialRequest -} from '../interfaces/oidc-issuer-sessions.interfaces'; -import { OIDCIssuanceService } from './oidc-issuance.service'; +import { user } from '@prisma/client'; @Controller() export class IssuanceController { - constructor( - private readonly issuanceService: IssuanceService, - private readonly oidcIssuanceService: OIDCIssuanceService - ) {} + constructor(private readonly issuanceService: IssuanceService) {} @MessagePattern({ cmd: 'get-issuance-records' }) async getIssuanceRecordsByOrgId(payload: { orgId: string; userId: string }): Promise { @@ -146,166 +135,4 @@ export class IssuanceController { async getFileDetailsAndFileDataByFileId(payload: { fileId: string; orgId: string }): Promise { return this.issuanceService.getFileDetailsAndFileDataByFileId(payload.fileId, payload.orgId); } - - @MessagePattern({ cmd: 'oidc-issuer-create' }) - async oidcIssuerCreate(payload: { - issueCredentialDto: IssuerCreation; - orgId: string; - userDetails: user; - }): Promise { - const { issueCredentialDto, orgId, userDetails } = payload; - return this.oidcIssuanceService.oidcIssuerCreate(issueCredentialDto, orgId, userDetails); - } - - @MessagePattern({ cmd: 'oidc-issuer-update' }) - async oidcIssuerUpdate(payload: { - issueUpdationDto: IssuerUpdation; - orgId: string; - userDetails: user; - }): Promise { - const { issueUpdationDto, orgId, userDetails } = payload; - return this.oidcIssuanceService.oidcIssuerUpdate(issueUpdationDto, orgId, userDetails); - } - - @MessagePattern({ cmd: 'oidc-issuer-get-by-id' }) - async oidcGetIssuerById(payload: { - issuerId: string; - orgId: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - }): Promise { - const { issuerId, orgId } = payload; - return this.oidcIssuanceService.oidcIssuerGetById(issuerId, orgId); - } - - @MessagePattern({ cmd: 'oidc-get-issuers-issuance' }) - async oidcGetIssuers(payload: { orgId: string }): Promise { - const { orgId } = payload; - return this.oidcIssuanceService.oidcIssuers(orgId); - } - - @MessagePattern({ cmd: 'oidc-delete-issuer' }) - async deleteOidcIssuer(payload: { - orgId: string; - userDetails: user; - issuerId: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - }): Promise { - const { orgId, userDetails, issuerId } = payload; - return this.oidcIssuanceService.deleteOidcIssuer(orgId, userDetails, issuerId); - } - - @MessagePattern({ cmd: 'oidc-template-create' }) - async oidcTemplateCreate(payload: { - CredentialTemplate: CreateCredentialTemplate; - orgId: string; - issuerId: string; - }): Promise { - const { CredentialTemplate, orgId, issuerId } = payload; - return this.oidcIssuanceService.createTemplate(CredentialTemplate, orgId, issuerId); - } - - @MessagePattern({ cmd: 'oidc-template-update' }) - async oidcTemplateUpdate(payload: { - templateId: string; - dto: UpdateCredentialTemplate; - orgId: string; - issuerId: string; - }): Promise { - const { templateId, dto, orgId, issuerId } = payload; - return this.oidcIssuanceService.updateTemplate(templateId, dto, orgId, issuerId); - } - - @MessagePattern({ cmd: 'oidc-template-delete' }) - async oidcTemplateDelete(payload: { - templateId: string; - orgId: string; - userDetails: user; - issuerId: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - }): Promise { - const { templateId, orgId, userDetails, issuerId } = payload; - return this.oidcIssuanceService.deleteTemplate(templateId, orgId, userDetails, issuerId); - } - - @MessagePattern({ cmd: 'oidc-template-find-id' }) - async oidcTemplateFindById(payload: { - templateId: string; - orgId: string; - userDetails: user; - issuerId: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - }): Promise { - const { templateId, orgId, userDetails, issuerId } = payload; - return this.oidcIssuanceService.findByIdTemplate(templateId, orgId, userDetails, issuerId); - } - - @MessagePattern({ cmd: 'oidc-template-find-all' }) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - async oidcTemplateFindAll(payload: { orgId: string; userDetails: user; issuerId: string }): Promise { - const { orgId, userDetails, issuerId } = payload; - return this.oidcIssuanceService.findAllTemplate(orgId, userDetails, issuerId); - } - - @MessagePattern({ cmd: 'oidc-create-credential-offer' }) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - async createOidcCredentialOffer(payload: { - oidcCredentialPayload: CreateOidcCredentialOffer; - orgId: string; - userDetails: user; - issuerId: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - }): Promise { - const { oidcCredentialPayload, orgId, userDetails, issuerId } = payload; - return this.oidcIssuanceService.createOidcCredentialOffer(oidcCredentialPayload, orgId, userDetails, issuerId); - } - - @MessagePattern({ cmd: 'oidc-update-credential-offer' }) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - async updateOidcCredentialOffer(payload: { - oidcUpdateCredentialPayload: UpdateCredentialRequest; - orgId: string; - issuerId: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - }): Promise { - const { oidcUpdateCredentialPayload, orgId, issuerId } = payload; - return this.oidcIssuanceService.updateOidcCredentialOffer(oidcUpdateCredentialPayload, orgId, issuerId); - } - - @MessagePattern({ cmd: 'oidc-credential-offer-get-by-id' }) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - async getCredentialOfferDetailsById(payload: { - offerId: string; - orgId: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - }): Promise { - const { offerId, orgId } = payload; - return this.oidcIssuanceService.getCredentialOfferDetailsById(offerId, orgId); - } - @MessagePattern({ cmd: 'oidc-credential-offer-get-all' }) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - async getAllCredentialOffers(payload: { - orgId: string; - getAllCredentialOffer: GetAllCredentialOffer; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - }): Promise { - const { orgId, getAllCredentialOffer } = payload; - return this.oidcIssuanceService.getCredentialOffers(orgId, getAllCredentialOffer); - } - - @MessagePattern({ cmd: 'oidc-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 { - const { orgId, credentialId } = payload; - return this.oidcIssuanceService.deleteCredentialOffers(orgId, credentialId); - } - - //TODO: complete the logic - @MessagePattern({ cmd: 'webhook-oidc-issue-credential' }) - async oidcIssueCredentialWebhook(payload: IssueCredentialWebhookPayload): Promise { - return this.issuanceService.getIssueCredentialWebhook(payload); - } } diff --git a/apps/issuance/src/issuance.module.ts b/apps/issuance/src/issuance.module.ts index 080907d74..84886a913 100644 --- a/apps/issuance/src/issuance.module.ts +++ b/apps/issuance/src/issuance.module.ts @@ -21,7 +21,6 @@ import { ConfigModule as PlatformConfig } from '@credebl/config/config.module'; import { ContextInterceptorModule } from '@credebl/context/contextInterceptorModule'; import { GlobalConfigModule } from '@credebl/config/global-config.module'; import { NATSClient } from '@credebl/common/NATSClient'; -import { OIDCIssuanceService } from './oidc-issuance.service'; @Module({ imports: [ @@ -52,7 +51,6 @@ import { OIDCIssuanceService } from './oidc-issuance.service'; controllers: [IssuanceController], providers: [ IssuanceService, - OIDCIssuanceService, IssuanceRepository, UserActivityRepository, PrismaService, diff --git a/apps/issuance/src/issuance.repository.ts b/apps/issuance/src/issuance.repository.ts index 6258a47af..0c0ae7182 100644 --- a/apps/issuance/src/issuance.repository.ts +++ b/apps/issuance/src/issuance.repository.ts @@ -14,11 +14,9 @@ import { IssueCredentials, IssuedCredentialStatus } from '../enum/issuance.enum' import { Prisma, agent_invitations, - credential_templates, credentials, file_data, file_upload, - oidc_issuer, org_agents, organisation, platform_config, @@ -32,7 +30,6 @@ import { IIssuedCredentialSearchParams } from 'apps/api-gateway/src/issuance/int import { IUserRequest } from '@credebl/user-request/user-request.interface'; import { PrismaService } from '@credebl/prisma-service'; import { ResponseMessages } from '@credebl/common/response-messages'; -import { IssuerMetadata, IssuerUpdation } from '../interfaces/oidc-issuance.interfaces'; @Injectable() export class IssuanceRepository { @@ -242,24 +239,7 @@ export class IssuanceRepository { organisationId = id; } - let schemaId = ''; - - if ( - issueCredentialDto?.metadata?.['_anoncreds/credential']?.schemaId || - issueCredentialDto?.['credentialData']?.offer?.jsonld?.credential?.['@context'][1] || - (issueCredentialDto?.state && - issueCredentialDto?.['credentialData']?.proposal?.jsonld?.credential?.['@context'][1]) - ) { - schemaId = - issueCredentialDto?.metadata?.['_anoncreds/credential']?.schemaId || - issueCredentialDto?.['credentialData']?.offer?.jsonld?.credential?.['@context'][1] || - issueCredentialDto?.['credentialData']?.proposal?.jsonld?.credential?.['@context'][1]; - } - - let credDefId = ''; - if (issueCredentialDto?.metadata?.['_anoncreds/credential']?.credentialDefinitionId) { - credDefId = issueCredentialDto?.metadata?.['_anoncreds/credential']?.credentialDefinitionId; - } + const schemaId = ''; const credentialDetails = await this.prisma.credentials.upsert({ where: { @@ -270,9 +250,7 @@ export class IssuanceRepository { createDateTime: issueCredentialDto?.createDateTime, threadId: issueCredentialDto?.threadId, connectionId: issueCredentialDto?.connectionId, - state: issueCredentialDto?.state, - credDefId, - ...(schemaId ? { schemaId } : {}) + state: issueCredentialDto?.state }, create: { createDateTime: issueCredentialDto?.createDateTime, @@ -282,7 +260,6 @@ export class IssuanceRepository { state: issueCredentialDto?.state, threadId: issueCredentialDto?.threadId, schemaId, - credDefId, credentialExchangeId: issueCredentialDto?.id, orgId: organisationId } @@ -796,186 +773,4 @@ export class IssuanceRepository { throw error; } } - - async getOidcIssuerByOrg(orgId: string): Promise { - try { - return await this.prisma.oidc_issuer.findMany({ - where: { createdBy: orgId }, - include: { - templates: true - }, - orderBy: { - createDateTime: 'desc' - } - }); - } catch (error) { - this.logger.error(`Error in getOidcIssuerByOrg: ${error.message}`); - throw error; - } - } - - async getOidcIssuerDetailsById(issuerId: string): Promise { - try { - return await this.prisma.oidc_issuer.findFirstOrThrow({ - where: { id: issuerId } - }); - } catch (error) { - this.logger.error(`Error in getOidcIssuerDetailsById: ${error.message}`); - throw error; - } - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - async addOidcIssuerDetails(issuerMetadata: IssuerMetadata, issuerProfileJson): Promise { - try { - const { publicIssuerId, createdById, orgAgentId, batchCredentialIssuanceSize } = issuerMetadata; - const oidcIssuerDetails = await this.prisma.oidc_issuer.create({ - data: { - metadata: issuerProfileJson, - publicIssuerId, - createdBy: createdById, - orgAgentId, - batchCredentialIssuanceSize - } - }); - - return oidcIssuerDetails; - } catch (error) { - this.logger.error(`[addOidcIssuerDetails] - error: ${JSON.stringify(error)}`); - throw error; - } - } - - async updateOidcIssuerDetails(createdById: string, issuerConfig: IssuerUpdation): Promise { - try { - const { issuerId, display, batchCredentialIssuanceSize } = issuerConfig; - const oidcIssuerDetails = await this.prisma.oidc_issuer.update({ - where: { id: issuerId }, - data: { - metadata: display as unknown as Prisma.InputJsonValue, - createdBy: createdById, - ...(batchCredentialIssuanceSize !== undefined ? { batchCredentialIssuanceSize } : {}) - } - }); - - return oidcIssuerDetails; - } catch (error) { - this.logger.error(`[addOidcIssuerDetails] - error: ${JSON.stringify(error)}`); - throw error; - } - } - - async deleteOidcIssuer(issuerId: string): Promise { - try { - return await this.prisma.oidc_issuer.delete({ - where: { id: issuerId } - }); - } catch (error) { - this.logger.error(`[deleteOidcIssuer] - error: ${JSON.stringify(error)}`); - throw error; - } - } - - async createTemplate( - issuerId: string, - data: Omit - ): Promise { - try { - return await this.prisma.credential_templates.create({ - data: { - ...data, - issuerId - } - }); - } catch (error) { - this.logger.error(`Error in createTemplate: ${error.message}`); - throw error; - } - } - - async getTemplateById(templateId: string): Promise { - try { - return await this.prisma.credential_templates.findUnique({ - where: { id: templateId } - }); - } catch (error) { - this.logger.error(`Error in getTemplateById: ${error.message}`); - throw error; - } - } - - async getTemplateByIds(templateIds: string[], issuerId: string): Promise { - try { - // Early return if empty input (avoids full table scan if someone passes []) - if (!Array.isArray(templateIds) || 0 === templateIds.length) { - return []; - } - - this.logger.debug(`getTemplateByIds templateIds=${JSON.stringify(templateIds)} issuerId=${issuerId}`); - - return await this.prisma.credential_templates.findMany({ - where: { - id: { in: templateIds }, - issuerId - } - }); - } catch (error) { - this.logger.error(`Error in getTemplateByIds: ${error?.message}`, error?.stack); - throw error; - } - } - - async getTemplateByNameForIssuer(name: string, issuerId: string): Promise { - try { - return await this.prisma.credential_templates.findMany({ - where: { - issuerId, - name: { - equals: name, - mode: 'insensitive' - } - } - }); - } catch (error) { - this.logger.error(`Error in getTemplateByNameForIssuer: ${error.message}`); - throw error; - } - } - - async getTemplatesByIssuerId(issuerId: string): Promise { - try { - return await this.prisma.credential_templates.findMany({ - where: { issuerId }, - orderBy: { - createdAt: 'desc' - } - }); - } catch (error) { - this.logger.error(`Error in getTemplatesByIssuer: ${error.message}`); - throw error; - } - } - - async updateTemplate(templateId: string, data: Partial): Promise { - try { - return await this.prisma.credential_templates.update({ - where: { id: templateId }, - data - }); - } catch (error) { - this.logger.error(`Error in updateTemplate: ${error.message}`); - throw error; - } - } - - async deleteTemplate(templateId: string): Promise { - try { - return await this.prisma.credential_templates.delete({ - where: { id: templateId } - }); - } catch (error) { - this.logger.error(`Error in deleteTemplate: ${error.message}`); - throw error; - } - } } diff --git a/apps/oid4vc-issuance/constant/issuance.ts b/apps/oid4vc-issuance/constant/issuance.ts new file mode 100644 index 000000000..d32498997 --- /dev/null +++ b/apps/oid4vc-issuance/constant/issuance.ts @@ -0,0 +1,6 @@ +import { AccessTokenSignerKeyType } from '../interfaces/oid4vc-issuance.interfaces'; + +export const dpopSigningAlgValuesSupported = ['RS256', 'ES256', 'EdDSA']; +export const credentialConfigurationsSupported = {}; +export const accessTokenSignerKeyType = 'ed25519' as AccessTokenSignerKeyType; +export const batchCredentialIssuanceDefault = 0; diff --git a/apps/oid4vc-issuance/enum/issuance.enum.ts b/apps/oid4vc-issuance/enum/issuance.enum.ts new file mode 100644 index 000000000..2d25b147f --- /dev/null +++ b/apps/oid4vc-issuance/enum/issuance.enum.ts @@ -0,0 +1,29 @@ +export enum SortFields { + CREATED_DATE_TIME = 'createDateTime', + SCHEMA_ID = 'schemaId', + CONNECTION_ID = 'connectionId', + STATE = 'state' +} + +export enum IssueCredentials { + proposalSent = 'proposal-sent', + proposalReceived = 'proposal-received', + offerSent = 'offer-sent', + offerReceived = 'offer-received', + declined = 'decliend', + requestSent = 'request-sent', + requestReceived = 'request-received', + credentialIssued = 'credential-issued', + credentialReceived = 'credential-received', + done = 'done', + abandoned = 'abandoned' +} + +export enum IssuedCredentialStatus { + offerSent = 'Offered', + done = 'Accepted', + abandoned = 'Declined', + received = 'Pending', + proposalReceived = 'Proposal Received', + credIssued = 'Credential Issued' +} diff --git a/apps/issuance/interfaces/oidc-issuance.interfaces.ts b/apps/oid4vc-issuance/interfaces/oid4vc-issuance.interfaces.ts similarity index 89% rename from apps/issuance/interfaces/oidc-issuance.interfaces.ts rename to apps/oid4vc-issuance/interfaces/oid4vc-issuance.interfaces.ts index 854480f70..1c55048af 100644 --- a/apps/issuance/interfaces/oidc-issuance.interfaces.ts +++ b/apps/oid4vc-issuance/interfaces/oid4vc-issuance.interfaces.ts @@ -1,3 +1,22 @@ +import { organisation } from '@prisma/client'; + +export interface OrgAgent { + organisation: organisation; + id: string; + createDateTime: Date; + createdBy: string; + lastChangedDateTime: Date; + lastChangedBy: string; + orgDid: string; + verkey: string; + agentEndPoint: string; + agentId: string; + isDidPublic: boolean; + ledgerId: string; + orgAgentTypeId: string; + tenantId: string; +} + export interface Claim { key: string; label: string; diff --git a/apps/issuance/interfaces/oidc-issuer-sessions.interfaces.ts b/apps/oid4vc-issuance/interfaces/oid4vc-issuer-sessions.interfaces.ts similarity index 100% rename from apps/issuance/interfaces/oidc-issuer-sessions.interfaces.ts rename to apps/oid4vc-issuance/interfaces/oid4vc-issuer-sessions.interfaces.ts diff --git a/apps/issuance/interfaces/oidc-template.interface.ts b/apps/oid4vc-issuance/interfaces/oid4vc-template.interfaces.ts similarity index 91% rename from apps/issuance/interfaces/oidc-template.interface.ts rename to apps/oid4vc-issuance/interfaces/oid4vc-template.interfaces.ts index 2025cfecd..36fd87e04 100644 --- a/apps/issuance/interfaces/oidc-template.interface.ts +++ b/apps/oid4vc-issuance/interfaces/oid4vc-template.interfaces.ts @@ -1,5 +1,5 @@ import { Prisma } from '@prisma/client'; -import { Display } from './oidc-issuance.interfaces'; +import { Display } from './oid4vc-issuance.interfaces'; export interface CredentialAttribute { mandatory?: boolean; diff --git a/apps/oid4vc-issuance/interfaces/oid4vc-wh-interfaces.ts b/apps/oid4vc-issuance/interfaces/oid4vc-wh-interfaces.ts new file mode 100644 index 000000000..3f878528e --- /dev/null +++ b/apps/oid4vc-issuance/interfaces/oid4vc-wh-interfaces.ts @@ -0,0 +1,48 @@ +export interface CredentialOfferPayload { + credential_issuer: string; + credential_configuration_ids: string[]; + grants: Record; + credentials: Record[]; +} + +export interface IssuanceMetadata { + issuerDid: string; + credentials: Record[]; +} + +export interface OidcIssueCredential { + _tags: Record; + metadata: Record; + issuedCredentials: Record[]; + id: string; + createdAt: string; // ISO date string + issuerId: string; + userPin: string; + preAuthorizedCode: string; + credentialOfferUri: string; + credentialOfferId: string; + credentialOfferPayload: CredentialOfferPayload; + issuanceMetadata: IssuanceMetadata; + state: string; + updatedAt: string; // ISO date string + contextCorrelationId: string; +} + +export interface CredentialOfferWebhookPayload { + credentialOfferId: string; + id: string; + State: string; + contextCorrelationId: string; +} + +export interface CredentialPayload { + orgId: string; + schemaId?: string; + connectionId?: string; + credDefid?: string; + threadId: string; + createdBy: string; + lastChangedBy: string; + state: string; + credentialExchangeId: string; +} diff --git a/apps/oid4vc-issuance/libs/helpers/credential-sessions.builder.ts b/apps/oid4vc-issuance/libs/helpers/credential-sessions.builder.ts new file mode 100644 index 000000000..85cf77e23 --- /dev/null +++ b/apps/oid4vc-issuance/libs/helpers/credential-sessions.builder.ts @@ -0,0 +1,349 @@ +// builder/credential-offer.builder.ts +/* eslint-disable @typescript-eslint/naming-convention, @typescript-eslint/explicit-function-return-type, @typescript-eslint/explicit-module-boundary-types, camelcase */ +import { Prisma, credential_templates } from '@prisma/client'; +import { GetAllCredentialOffer, SignerOption } from '../../interfaces/oid4vc-issuer-sessions.interfaces'; +/* ============================================================================ + Domain Types +============================================================================ */ + +type ValueType = 'string' | 'date' | 'number' | 'boolean' | string; + +interface TemplateAttribute { + display: { name: string; locale: string }[]; + mandatory: boolean; + value_type: ValueType; +} +type TemplateAttributes = Record; + +export enum CredentialFormat { + SdJwtVc = 'vc+sd-jwt', + Mdoc = 'mdoc' +} + +export enum SignerMethodOption { + DID = 'did', + X5C = 'x5c' +} + +export type DisclosureFrame = Record>; + +export interface CredentialRequestDtoLike { + /** maps to credential_templates.id (the template to use) */ + templateId: string; + /** per-template claims */ + payload: Record; + /** optional selective disclosure map */ + disclosureFrame?: DisclosureFrame; +} + +export interface CreateOidcCredentialOfferDtoLike { + credentials: CredentialRequestDtoLike[]; + + // Exactly one of the two must be provided (XOR) + preAuthorizedCodeFlowConfig?: { + txCode: { description?: string; length: number; input_mode: 'numeric' | 'text' | 'alphanumeric' }; + authorizationServerUrl: string; + }; + authorizationCodeFlowConfig?: { + authorizationServerUrl: string; + }; + + // NOTE: issuerId is intentionally NOT emitted in the final payload + publicIssuerId?: string; +} + +export interface ResolvedSignerOption { + method: 'did' | 'x5c'; + did?: string; + x5c?: string[]; +} + +/* ============================================================================ + Strong return types +============================================================================ */ + +export interface BuiltCredential { + /** e.g., "BirthCertificateCredential-sdjwt" or "DrivingLicenseCredential-mdoc" */ + credentialSupportedId: string; + signerOptions?: ResolvedSignerOption; + /** Derived from template.format ("vc+sd-jwt" | "mdoc") */ + format: CredentialFormat; + /** User-provided payload (validated, with vct removed) */ + payload: Record; + /** Optional disclosure frame (usually for SD-JWT) */ + disclosureFrame?: DisclosureFrame; +} + +export interface BuiltCredentialOfferBase { + /** Resolved signer option (DID or x5c) */ + signerOption?: ResolvedSignerOption; + /** Normalized credential entries */ + credentials: BuiltCredential[]; + /** Optional public issuer id to include */ + publicIssuerId?: string; +} + +/** Final payload = base + EXACTLY ONE of the two flows */ +export type CredentialOfferPayload = BuiltCredentialOfferBase & + ( + | { + preAuthorizedCodeFlowConfig: { + txCode: { description?: string; length: number; input_mode: 'numeric' | 'text' | 'alphanumeric' }; + authorizationServerUrl: string; + }; + authorizationCodeFlowConfig?: never; + } + | { + authorizationCodeFlowConfig: { + authorizationServerUrl: string; + }; + preAuthorizedCodeFlowConfig?: never; + } + ); + +/* ============================================================================ + Small Utilities +============================================================================ */ + +const isNil = (v: unknown): v is null | undefined => null == v; +const isEmptyString = (v: unknown): boolean => 'string' === typeof v && '' === v.trim(); +const isRecord = (v: unknown): v is Record => Boolean(v) && 'object' === typeof v && !Array.isArray(v); + +/** Map DB format string -> API enum */ +function mapDbFormatToApiFormat(db: string): CredentialFormat { + const norm = db.toLowerCase().replace(/_/g, '-'); + if ('sd-jwt' === norm || 'vc+sd-jwt' === norm || 'sdjwt' === norm || 'sd+jwt-vc' === norm) { + return CredentialFormat.SdJwtVc; + } + if ('mdoc' === norm || 'mso-mdoc' === norm || 'mso-mdoc' === norm) { + return CredentialFormat.Mdoc; + } + throw new Error(`Unsupported template format: ${db}`); +} + +/** Map API enum -> id suffix required for credentialSupportedId */ +function formatSuffix(api: CredentialFormat): 'sdjwt' | 'mdoc' { + return api === CredentialFormat.SdJwtVc ? 'sdjwt' : 'mdoc'; +} + +/* ============================================================================ + Validation of Payload vs Template Attributes +============================================================================ */ + +/** Throw if any template-mandatory claim is missing/empty in payload. */ +function assertMandatoryClaims( + payload: Record, + attributes: TemplateAttributes, + ctx: { templateId: string } +): void { + const missing: string[] = []; + for (const [claim, def] of Object.entries(attributes)) { + if (!def?.mandatory) { + continue; + } + const val = payload[claim]; + if (isNil(val) || isEmptyString(val)) { + missing.push(claim); + } + } + if (missing.length) { + throw new Error(`Missing mandatory claims for template "${ctx.templateId}": ${missing.join(', ')}`); + } +} + +/* ============================================================================ + JsonValue → TemplateAttributes Narrowing (Type Guards) +============================================================================ */ + +function isDisplayArray(v: unknown): v is { name: string; locale: string }[] { + return Array.isArray(v) && v.every((d) => isRecord(d) && 'string' === typeof d.name && 'string' === typeof d.locale); +} + +function isTemplateAttribute(v: unknown): v is TemplateAttribute { + return ( + isRecord(v) && isDisplayArray(v.display) && 'boolean' === typeof v.mandatory && 'string' === typeof v.value_type + ); +} + +/** Accept `unknown` so predicate type (TemplateAttributes) is assignable to parameter type. */ +function isTemplateAttributes(v: unknown): v is TemplateAttributes { + if (!isRecord(v)) { + return false; + } + return Object.values(v).every(isTemplateAttribute); +} + +/** Runtime assert + narrow Prisma.JsonValue → TemplateAttributes */ +function ensureTemplateAttributes(v: Prisma.JsonValue): TemplateAttributes { + if (!isTemplateAttributes(v)) { + throw new Error('Invalid template.attributes shape. Expecting TemplateAttributes map.'); + } + return v; +} + +/* ============================================================================ + Builders +============================================================================ */ + +/** Build one credential block normalized to API format (using the template's format). */ +function buildOneCredential( + cred: CredentialRequestDtoLike, + template: credential_templates, + attrs: TemplateAttributes, + signerOptions?: SignerOption[] +): BuiltCredential { + // 1) Validate payload against template attributes + assertMandatoryClaims(cred.payload, attrs, { templateId: cred.templateId }); + + // 2) Decide API format from DB format + const apiFormat = mapDbFormatToApiFormat(template.format); + + // 3) Build supportedId from template.name + suffix ("-sdjwt" | "-mdoc") + const suffix = formatSuffix(apiFormat); + const credentialSupportedId = `${template.name}-${suffix}`; + + // 4) Strip vct ALWAYS (per requirement) + const payload = { ...(cred.payload as Record) }; + delete (payload as Record).vct; + + return { + credentialSupportedId, // e.g., "BirthCertificateCredential-sdjwt" + signerOptions: signerOptions[0], + format: apiFormat, // 'vc+sd-jwt' | 'mdoc' + payload, // without vct + ...(cred.disclosureFrame ? { disclosureFrame: cred.disclosureFrame } : {}) + }; +} + +/** + * Build the full OID4VC credential offer payload. + * - Verifies template IDs + * - Validates mandatory claims per template + * - Normalizes formats & IDs + * - Enforces XOR of flow configs + * - Removes issuerId from the final envelope + * - Removes vct from all payloads + * - Sets credentialSupportedId = "-sdjwt|mdoc" + */ +export function buildCredentialOfferPayload( + dto: CreateOidcCredentialOfferDtoLike, + templates: credential_templates[], + signerOptions?: SignerOption[] +): CredentialOfferPayload { + // Index templates + const byId = new Map(templates.map((t) => [t.id, t])); + + // Verify all requested templateIds exist + const unknown = dto.credentials.map((c) => c.templateId).filter((id) => !byId.has(id)); + if (unknown.length) { + throw new Error(`Unknown template ids: ${unknown.join(', ')}`); + } + + // Build credentials + const credentials: BuiltCredential[] = dto.credentials.map((cred) => { + const template = byId.get(cred.templateId)!; + const attrs = ensureTemplateAttributes(template.attributes); // narrow JsonValue safely + return buildOneCredential(cred, template, attrs, signerOptions); + }); + + // --- Base envelope (issuerId deliberately NOT included) --- + const base: BuiltCredentialOfferBase = { + credentials, + ...(dto.publicIssuerId ? { publicIssuerId: dto.publicIssuerId } : {}) + }; + + // XOR flow selection (defensive) + const hasPre = Boolean(dto.preAuthorizedCodeFlowConfig); + const hasAuth = Boolean(dto.authorizationCodeFlowConfig); + if (hasPre === hasAuth) { + throw new Error('Provide exactly one of preAuthorizedCodeFlowConfig or authorizationCodeFlowConfig.'); + } + + if (hasPre) { + return { + ...base, + preAuthorizedCodeFlowConfig: dto.preAuthorizedCodeFlowConfig! // definite since hasPre + }; + } + + return { + ...base, + authorizationCodeFlowConfig: dto.authorizationCodeFlowConfig! // definite since !hasPre + }; +} + +// ----------------------------------------------------------------------------- +// Builder: Update Credential Offer +// ----------------------------------------------------------------------------- +export function buildUpdateCredentialOfferPayload( + dto: CreateOidcCredentialOfferDtoLike, + templates: credential_templates[] +): { credentials: BuiltCredential[] } { + // Index templates by id + const byId = new Map(templates.map((t) => [t.id, t])); + + // Validate all templateIds exist + const unknown = dto.credentials.map((c) => c.templateId).filter((id) => !byId.has(id)); + if (unknown.length) { + throw new Error(`Unknown template ids: ${unknown.join(', ')}`); + } + + // Validate each credential against its template + const credentials: BuiltCredential[] = dto.credentials.map((cred) => { + const template = byId.get(cred.templateId)!; + const attrs = ensureTemplateAttributes(template.attributes); // safely narrow JsonValue + + // check that all payload keys exist in template attributes + const payloadKeys = Object.keys(cred.payload); + const invalidKeys = payloadKeys.filter((k) => !attrs[k]); + if (invalidKeys.length) { + throw new Error(`Invalid attributes for template "${cred.templateId}": ${invalidKeys.join(', ')}`); + } + + // also validate mandatory fields are present + assertMandatoryClaims(cred.payload, attrs, { templateId: cred.templateId }); + + // build minimal normalized credential (no vct, issuerId, etc.) + const apiFormat = mapDbFormatToApiFormat(template.format); + const suffix = formatSuffix(apiFormat); + const credentialSupportedId = `${template.name}-${suffix}`; + return { + credentialSupportedId, + format: apiFormat, + payload: cred.payload, + ...(cred.disclosureFrame ? { disclosureFrame: cred.disclosureFrame } : {}) + }; + }); + + // Only return credentials array here (update flow doesn't need preAuth/auth configs) + return { + credentials + }; +} + +export function buildCredentialOfferUrl(baseUrl: string, getAllCredentialOffer: GetAllCredentialOffer): string { + const criteriaParams: string[] = []; + + if (getAllCredentialOffer.publicIssuerId) { + criteriaParams.push(`publicIssuerId=${encodeURIComponent(getAllCredentialOffer.publicIssuerId)}`); + } + + if (getAllCredentialOffer.preAuthorizedCode) { + criteriaParams.push(`preAuthorizedCode=${encodeURIComponent(getAllCredentialOffer.preAuthorizedCode)}`); + } + + if (getAllCredentialOffer.state) { + criteriaParams.push(`state=${encodeURIComponent(getAllCredentialOffer.state)}`); + } + + if (getAllCredentialOffer.credentialOfferUri) { + criteriaParams.push(`credentialOfferUri=${encodeURIComponent(getAllCredentialOffer.credentialOfferUri)}`); + } + + if (getAllCredentialOffer.authorizationCode) { + criteriaParams.push(`authorizationCode=${encodeURIComponent(getAllCredentialOffer.authorizationCode)}`); + } + + // Append query string if any params exist + return 0 < criteriaParams.length ? `${baseUrl}?${criteriaParams.join('&')}` : baseUrl; +} diff --git a/apps/oid4vc-issuance/libs/helpers/issuer.metadata.ts b/apps/oid4vc-issuance/libs/helpers/issuer.metadata.ts new file mode 100644 index 000000000..78c4da88e --- /dev/null +++ b/apps/oid4vc-issuance/libs/helpers/issuer.metadata.ts @@ -0,0 +1,262 @@ +/* eslint-disable camelcase */ +import { oidc_issuer, Prisma } from '@prisma/client'; +import { batchCredentialIssuanceDefault } from '../../constant/issuance'; +import { CreateOidcCredentialOffer } from '../../interfaces/oid4vc-issuer-sessions.interfaces'; +import { IssuerResponse } from 'apps/oid4vc-issuance/interfaces/oid4vc-issuance.interfaces'; + +type AttributeDisplay = { name: string; locale: string }; +type AttributeDef = { + display?: AttributeDisplay[]; + mandatory?: boolean; + value_type: 'string' | 'date' | 'number' | 'boolean' | string; +}; +type AttributesMap = Record; + +type CredentialDisplayItem = { + logo?: { uri: string; alt_text?: string }; + name: string; + locale?: string; + description?: string; +}; +type Appearance = { + display: CredentialDisplayItem[]; +}; + +type CredentialConfig = { + format: string; + vct?: string; + scope: string; + doctype?: string; + claims: Record< + string, + { + mandatory?: boolean; + value_type: string; + display?: AttributeDisplay[]; + } + >; + credential_signing_alg_values_supported: string[]; + cryptographic_binding_methods_supported: string[]; + display: { name: string; description?: string; locale?: string }[]; +}; + +type CredentialConfigurationsSupported = { + credentialConfigurationsSupported: Record; +}; + +// ---- Static Lists (as requested) ---- +const STATIC_CREDENTIAL_ALGS = ['ES256', 'EdDSA'] as const; +const STATIC_BINDING_METHODS = ['did:key'] as const; +const DOCTYPE = 'org.iso.18013.5.1'; // for mso_mdoc format +const MSO_MDOC = 'mso_mdoc'; // alternative format value + +// Safe coercion helpers +function coerceJsonObject(v: Prisma.JsonValue): T | null { + if (null == v) { + return null; + } + if ('string' === typeof v) { + try { + return JSON.parse(v) as T; + } catch { + return null; + } + } + return v as unknown as T; // already a JsonObject/JsonArray +} +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function isAttributesMap(x: any): x is AttributesMap { + return x && 'object' === typeof x && !Array.isArray(x); +} +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function isAppearance(x: any): x is Appearance { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return x && 'object' === typeof x && Array.isArray((x as any).display); +} + +// Prisma row shape +type TemplateRowPrisma = { + id: string; + name: string; + description?: string | null; + format?: string | null; + canBeRevoked?: boolean | null; + attributes: Prisma.JsonValue; // JsonValue from DB + appearance: Prisma.JsonValue; // JsonValue from DB + issuerId: string; + createdAt?: Date | string; + updatedAt?: Date | string; +}; + +/** + * Build agent payload from Prisma rows (attributes/appearance are Prisma.JsonValue). + * Safely coerces JSON and then builds the same structure as Builder #2. + */ +export function buildCredentialConfigurationsSupported( + templates: TemplateRowPrisma[], + opts?: { + vct?: string; + scopeVct?: string; + keyResolver?: (t: TemplateRowPrisma) => string; + format?: string; + } +): CredentialConfigurationsSupported { + const format = opts?.format ?? 'vc+sd-jwt'; + const credentialConfigurationsSupported: Record = {}; + for (const t of templates) { + // Coerce JSON fields + const attrs = coerceJsonObject(t.attributes); + const app = coerceJsonObject(t.appearance); + + if (!isAttributesMap(attrs)) { + throw new Error(`Template ${t.id}: invalid attributes JSON`); + } + if (!isAppearance(app)) { + throw new Error(`Template ${t.id}: invalid appearance JSON (missing display)`); + } + + // ---- dynamic format per row ---- + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const rowFormat = (t as any).format ?? format; + const suffix = rowFormat === `${MSO_MDOC}` ? 'mdoc' : 'sdjwt'; + + // key: keep your keyResolver override; otherwise include suffix + const key = 'function' === typeof opts?.keyResolver ? opts.keyResolver(t) : `${t.name}-${suffix}`; + + // vct/scope: vct only for non-mdoc; scope always uses suffix + const vct = opts?.vct ?? t.name; + const scopeBase = opts?.scopeVct ?? vct; + const scope = `openid4vc:credential:${scopeBase}-${suffix}`; + const claims = Object.fromEntries( + Object.entries(attrs).map(([claimName, def]) => { + const d = def as AttributeDef; + return [ + claimName, + { + value_type: d.value_type, + mandatory: d.mandatory ?? false, // always include, default to false + display: Array.isArray(d.display) ? d.display.map((x) => ({ name: x.name, locale: x.locale })) : undefined + } + ]; + }) + ); + + const display = + app.display.map((d) => ({ + name: d.name, + description: d.description, + locale: d.locale + })) ?? []; + + // assemble per-template config + credentialConfigurationsSupported[key] = { + format: rowFormat, + scope, + claims, + credential_signing_alg_values_supported: [...STATIC_CREDENTIAL_ALGS], + cryptographic_binding_methods_supported: [...STATIC_BINDING_METHODS], + display, + ...(rowFormat === `${MSO_MDOC}` + ? { doctype: `${DOCTYPE}` } // static for mdoc + : { vct }) // keep vct only for non-mdoc + }; + } + + return { + credentialConfigurationsSupported + }; +} + +// Default DPoP list for issuer-level metadata (match your example) +const ISSUER_DPOP_ALGS_DEFAULT = ['RS256', 'ES256'] as const; + +// ---------- Safe coercion ---------- +function coerceJson(v: Prisma.JsonValue): T | null { + if (null == v) { + return null; + } + if ('string' === typeof v) { + try { + return JSON.parse(v) as T; + } catch { + return null; + } + } + return v as unknown as T; +} + +type DisplayItem = { + name: string; + locale?: string; + description?: string; + logo?: { uri: string; alt_text?: string }; +}; + +function isDisplayArray(x: unknown): x is DisplayItem[] { + return ( + Array.isArray(x) && + x.every( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (i) => i && 'object' === typeof i && 'string' === typeof (i as any).name + ) + ); +} + +// ---------- Builder you asked for ---------- +/** + * Build issuer metadata payload from issuer row + credential configurations. + * + * @param credentialConfigurations Object with credentialConfigurationsSupported (from your template builder) + * @param oidcIssuer OID4VC issuer row (uses publicIssuerId and metadata -> display) + * @param opts Optional overrides: dpopAlgs[], accessTokenSignerKeyType + */ +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export function buildIssuerPayload( + credentialConfigurations: CredentialConfigurationsSupported, + oidcIssuer: oidc_issuer, + opts?: { + dpopAlgs?: string[]; + accessTokenSignerKeyType?: string; + } +) { + if (!oidcIssuer?.publicIssuerId || 'string' !== typeof oidcIssuer.publicIssuerId) { + throw new Error('Invalid issuer: missing publicIssuerId'); + } + + const rawDisplay = coerceJson(oidcIssuer.metadata); + const display: DisplayItem[] = isDisplayArray(rawDisplay) ? rawDisplay : []; + + return { + display, + dpopSigningAlgValuesSupported: opts?.dpopAlgs ?? [...ISSUER_DPOP_ALGS_DEFAULT], + credentialConfigurationsSupported: credentialConfigurations.credentialConfigurationsSupported ?? {}, + batchCredentialIssuance: { + batchSize: oidcIssuer?.batchCredentialIssuanceSize ?? batchCredentialIssuanceDefault + } + }; +} + +export function extractTemplateIds(offer: CreateOidcCredentialOffer): string[] { + if (!offer?.credentials || !Array.isArray(offer.credentials)) { + return []; + } + + return offer.credentials.map((c) => c.templateId).filter((id): id is string => Boolean(id)); +} + +export function normalizeJson(input: unknown): IssuerResponse { + if ('string' === typeof input) { + return JSON.parse(input) as IssuerResponse; + } + if (input && 'object' === typeof input) { + return input as IssuerResponse; + } + throw new Error('Expected a JSON object or JSON string'); +} + +export function encodeIssuerPublicId(publicIssuerId: string): string { + if (!publicIssuerId) { + throw new Error('issuerPublicId is required'); + } + return encodeURIComponent(publicIssuerId.trim()); +} diff --git a/apps/oid4vc-issuance/src/main.ts b/apps/oid4vc-issuance/src/main.ts new file mode 100644 index 000000000..3c6933fc9 --- /dev/null +++ b/apps/oid4vc-issuance/src/main.ts @@ -0,0 +1,23 @@ +import { NestFactory } from '@nestjs/core'; +import { HttpExceptionFilter } from 'libs/http-exception.filter'; +import { Logger } from '@nestjs/common'; +import { MicroserviceOptions, Transport } from '@nestjs/microservices'; +import { getNatsOptions } from '@credebl/common/nats.config'; +import { CommonConstants } from '@credebl/common/common.constant'; +import NestjsLoggerServiceAdapter from '@credebl/logger/nestjsLoggerServiceAdapter'; +import { Oid4vcIssuanceModule } from './oid4vc-issuance.module'; + +const logger = new Logger(); + +async function bootstrap(): Promise { + const app = await NestFactory.createMicroservice(Oid4vcIssuanceModule, { + transport: Transport.NATS, + options: getNatsOptions(CommonConstants.ISSUANCE_SERVICE, process.env.ISSUANCE_NKEY_SEED) + }); + app.useLogger(app.get(NestjsLoggerServiceAdapter)); + app.useGlobalFilters(new HttpExceptionFilter()); + + await app.listen(); + logger.log('OID4VC-Issuance-Service Microservice is listening to NATS '); +} +bootstrap(); diff --git a/apps/oid4vc-issuance/src/oid4vc-issuance.controller.ts b/apps/oid4vc-issuance/src/oid4vc-issuance.controller.ts new file mode 100644 index 000000000..08cf5e45e --- /dev/null +++ b/apps/oid4vc-issuance/src/oid4vc-issuance.controller.ts @@ -0,0 +1,172 @@ +/* eslint-disable camelcase */ +import { Controller } from '@nestjs/common'; +import { Oid4vcIssuanceService } from './oid4vc-issuance.service'; +import { IssuerCreation, IssuerUpdation } from '../interfaces/oid4vc-issuance.interfaces'; +import { MessagePattern } from '@nestjs/microservices'; +import { user, oidc_issuer, credential_templates } from '@prisma/client'; +import { + CreateOidcCredentialOffer, + UpdateCredentialRequest, + GetAllCredentialOffer +} from '../interfaces/oid4vc-issuer-sessions.interfaces'; +import { CreateCredentialTemplate, UpdateCredentialTemplate } from '../interfaces/oid4vc-template.interfaces'; +import { CredentialOfferWebhookPayload } from '../interfaces/oid4vc-wh-interfaces'; + +@Controller() +export class Oid4vcIssuanceController { + constructor(private readonly oid4vcIssuanceService: Oid4vcIssuanceService) {} + + @MessagePattern({ cmd: 'oid4vc-issuer-create' }) + async oidcIssuerCreate(payload: { + issueCredentialDto: IssuerCreation; + orgId: string; + userDetails: user; + }): Promise { + const { issueCredentialDto, orgId, userDetails } = payload; + return this.oid4vcIssuanceService.oidcIssuerCreate(issueCredentialDto, orgId, userDetails); + } + + @MessagePattern({ cmd: 'oid4vc-issuer-update' }) + async oidcIssuerUpdate(payload: { + issueUpdationDto: IssuerUpdation; + orgId: string; + userDetails: user; + }): Promise { + const { issueUpdationDto, orgId, userDetails } = payload; + return this.oid4vcIssuanceService.oidcIssuerUpdate(issueUpdationDto, orgId, userDetails); + } + + @MessagePattern({ cmd: 'oid4vc-issuer-get-by-id' }) + async oidcGetIssuerById(payload: { + issuerId: string; + orgId: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }): Promise { + const { issuerId, orgId } = payload; + return this.oid4vcIssuanceService.oidcIssuerGetById(issuerId, orgId); + } + + @MessagePattern({ cmd: 'oid4vc-get-issuers-issuance' }) + async oidcGetIssuers(payload: { orgId: string }): Promise { + const { orgId } = payload; + return this.oid4vcIssuanceService.oidcIssuers(orgId); + } + + @MessagePattern({ cmd: 'oid4vc-delete-issuer' }) + async deleteOidcIssuer(payload: { + orgId: string; + userDetails: user; + issuerId: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }): Promise { + const { orgId, userDetails, issuerId } = payload; + return this.oid4vcIssuanceService.deleteOidcIssuer(orgId, userDetails, issuerId); + } + + @MessagePattern({ cmd: 'oid4vc-template-create' }) + async oidcTemplateCreate(payload: { + CredentialTemplate: CreateCredentialTemplate; + orgId: string; + issuerId: string; + }): Promise { + const { CredentialTemplate, orgId, issuerId } = payload; + return this.oid4vcIssuanceService.createTemplate(CredentialTemplate, orgId, issuerId); + } + + @MessagePattern({ cmd: 'oid4vc-template-update' }) + async oidcTemplateUpdate(payload: { + templateId: string; + dto: UpdateCredentialTemplate; + orgId: string; + issuerId: string; + }): Promise { + const { templateId, dto, orgId, issuerId } = payload; + return this.oid4vcIssuanceService.updateTemplate(templateId, dto, orgId, issuerId); + } + + @MessagePattern({ cmd: 'oid4vc-template-delete' }) + async oidcTemplateDelete(payload: { + templateId: string; + orgId: string; + userDetails: user; + issuerId: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }): Promise { + const { templateId, orgId, userDetails, issuerId } = payload; + return this.oid4vcIssuanceService.deleteTemplate(templateId, orgId, userDetails, issuerId); + } + + @MessagePattern({ cmd: 'oid4vc-template-find-id' }) + async oidcTemplateFindById(payload: { templateId: string; orgId: string }): Promise { + const { templateId, orgId } = payload; + return this.oid4vcIssuanceService.findByIdTemplate(templateId, orgId); + } + + @MessagePattern({ cmd: 'oid4vc-template-find-all' }) + async oidcTemplateFindAll(payload: { orgId: string; issuerId: string }): Promise { + const { orgId, issuerId } = payload; + return this.oid4vcIssuanceService.findAllTemplate(orgId, issuerId); + } + + @MessagePattern({ cmd: 'oid4vc-create-credential-offer' }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async createOidcCredentialOffer(payload: { + oidcCredentialPayload: CreateOidcCredentialOffer; + orgId: string; + userDetails: user; + issuerId: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }): Promise { + const { oidcCredentialPayload, orgId, userDetails, issuerId } = payload; + return this.oid4vcIssuanceService.createOidcCredentialOffer(oidcCredentialPayload, orgId, userDetails, issuerId); + } + + @MessagePattern({ cmd: 'oid4vc-update-credential-offer' }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async updateOidcCredentialOffer(payload: { + oidcUpdateCredentialPayload: UpdateCredentialRequest; + orgId: string; + issuerId: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }): Promise { + const { oidcUpdateCredentialPayload, orgId, issuerId } = payload; + return this.oid4vcIssuanceService.updateOidcCredentialOffer(oidcUpdateCredentialPayload, orgId, issuerId); + } + + @MessagePattern({ cmd: 'oid4vc-credential-offer-get-by-id' }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async getCredentialOfferDetailsById(payload: { + offerId: string; + orgId: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }): Promise { + const { offerId, orgId } = payload; + return this.oid4vcIssuanceService.getCredentialOfferDetailsById(offerId, orgId); + } + @MessagePattern({ cmd: 'oid4vc-credential-offer-get-all' }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async getAllCredentialOffers(payload: { + orgId: string; + getAllCredentialOffer: GetAllCredentialOffer; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }): Promise { + const { orgId, getAllCredentialOffer } = payload; + return this.oid4vcIssuanceService.getCredentialOffers(orgId, getAllCredentialOffer); + } + + @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 { + const { orgId, credentialId } = payload; + return this.oid4vcIssuanceService.deleteCredentialOffers(orgId, credentialId); + } + + @MessagePattern({ cmd: 'webhook-oid4vc-issue-credential' }) + async oidcIssueCredentialWebhook(payload: CredentialOfferWebhookPayload): 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 new file mode 100644 index 000000000..b882032cb --- /dev/null +++ b/apps/oid4vc-issuance/src/oid4vc-issuance.module.ts @@ -0,0 +1,36 @@ +import { Logger, Module } from '@nestjs/common'; +import { Oid4vcIssuanceController } from './oid4vc-issuance.controller'; +import { Oid4vcIssuanceService } from './oid4vc-issuance.service'; +import { ClientsModule, Transport } from '@nestjs/microservices'; +import { getNatsOptions } from '@credebl/common/nats.config'; +import { CommonModule } from '@credebl/common'; +import { CommonConstants } from '@credebl/common/common.constant'; +import { GlobalConfigModule } from '@credebl/config'; +import { ContextInterceptorModule } from '@credebl/context'; +import { LoggerModule } from '@credebl/logger'; +import { CacheModule } from '@nestjs/cache-manager'; +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'; + +@Module({ + imports: [ + ClientsModule.register([ + { + name: 'NATS_CLIENT', + transport: Transport.NATS, + options: getNatsOptions(CommonConstants.OIDC4VC_ISSUANCE_SERVICE, process.env.OIDC4VC_ISSUANCE_NKEY_SEED) + } + ]), + CommonModule, + GlobalConfigModule, + LoggerModule, + PlatformConfig, + ContextInterceptorModule, + CacheModule.register() + ], + controllers: [Oid4vcIssuanceController], + providers: [Oid4vcIssuanceService, Oid4vcIssuanceRepository, PrismaService, Logger, NATSClient] +}) +export class Oid4vcIssuanceModule {} diff --git a/apps/oid4vc-issuance/src/oid4vc-issuance.repository.ts b/apps/oid4vc-issuance/src/oid4vc-issuance.repository.ts new file mode 100644 index 000000000..6c12475fb --- /dev/null +++ b/apps/oid4vc-issuance/src/oid4vc-issuance.repository.ts @@ -0,0 +1,278 @@ +/* eslint-disable camelcase */ +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +// eslint-disable-next-line camelcase +import { Prisma, credential_templates, oidc_issuer, org_agents } from '@prisma/client'; +import { PrismaService } from '@credebl/prisma-service'; +import { IssuerMetadata, IssuerUpdation, OrgAgent } from '../interfaces/oid4vc-issuance.interfaces'; +import { ResponseMessages } from '@credebl/common/response-messages'; + +@Injectable() +export class Oid4vcIssuanceRepository { + constructor( + private readonly prisma: PrismaService, + private readonly logger: Logger + ) {} + + async getAgentEndPoint(orgId: string): Promise { + try { + const agentDetails = await this.prisma.org_agents.findFirst({ + where: { + orgId + }, + include: { + organisation: true + } + }); + + if (!agentDetails) { + throw new NotFoundException(ResponseMessages.issuance.error.agentEndPointNotFound); + } + + return agentDetails; + } catch (error) { + this.logger.error(`Error in get getAgentEndPoint: ${error.message} `); + throw error; + } + } + + async getOrgAgentType(orgAgentId: string): Promise { + try { + const { agent } = await this.prisma.org_agents_type.findFirst({ + where: { + id: orgAgentId + } + }); + + return agent; + } catch (error) { + this.logger.error(`[getOrgAgentType] - error: ${JSON.stringify(error)}`); + throw error; + } + } + + async getOrganizationByTenantId(tenantId: string): Promise { + try { + return this.prisma.org_agents.findFirst({ + where: { + tenantId + } + }); + } catch (error) { + this.logger.error(`Error in getOrganization in issuance repository: ${error.message} `); + throw error; + } + } + + async storeOidcCredentialDetails(credentialPayload): Promise { + try { + const { credentialOfferId, state, offerId, contextCorrelationId, orgId } = credentialPayload; + const credentialDetails = await this.prisma.oid4vc_credentials.upsert({ + where: { + offerId + }, + update: { + lastChangedBy: orgId, + credentialOfferId, + contextCorrelationId, + state + }, + create: { + lastChangedBy: orgId, + createdBy: orgId, + state, + orgId, + credentialOfferId, + offerId, + contextCorrelationId + } + }); + + return credentialDetails; + } catch (error) { + this.logger.error(`Error in get storeOidcCredentialDetails: ${error.message} `); + throw error; + } + } + + async getOidcIssuerByOrg(orgId: string): Promise { + try { + return await this.prisma.oidc_issuer.findMany({ + where: { createdBy: orgId }, + include: { + templates: true + }, + orderBy: { + createDateTime: 'desc' + } + }); + } catch (error) { + this.logger.error(`Error in getOidcIssuerByOrg: ${error.message}`); + throw error; + } + } + + async getOidcIssuerDetailsById(issuerId: string): Promise { + try { + return await this.prisma.oidc_issuer.findFirstOrThrow({ + where: { id: issuerId } + }); + } catch (error) { + this.logger.error(`Error in getOidcIssuerDetailsById: ${error.message}`); + throw error; + } + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async addOidcIssuerDetails(issuerMetadata: IssuerMetadata, issuerProfileJson): Promise { + try { + const { publicIssuerId, createdById, orgAgentId, batchCredentialIssuanceSize } = issuerMetadata; + const oidcIssuerDetails = await this.prisma.oidc_issuer.create({ + data: { + metadata: issuerProfileJson, + publicIssuerId, + createdBy: createdById, + orgAgentId, + batchCredentialIssuanceSize + } + }); + + return oidcIssuerDetails; + } catch (error) { + this.logger.error(`[addOidcIssuerDetails] - error: ${JSON.stringify(error)}`); + throw error; + } + } + + async updateOidcIssuerDetails(createdById: string, issuerConfig: IssuerUpdation): Promise { + try { + const { issuerId, display, batchCredentialIssuanceSize } = issuerConfig; + const oidcIssuerDetails = await this.prisma.oidc_issuer.update({ + where: { id: issuerId }, + data: { + metadata: display as unknown as Prisma.InputJsonValue, + createdBy: createdById, + ...(batchCredentialIssuanceSize !== undefined ? { batchCredentialIssuanceSize } : {}) + } + }); + + return oidcIssuerDetails; + } catch (error) { + this.logger.error(`[addOidcIssuerDetails] - error: ${JSON.stringify(error)}`); + throw error; + } + } + + async deleteOidcIssuer(issuerId: string): Promise { + try { + return await this.prisma.oidc_issuer.delete({ + where: { id: issuerId } + }); + } catch (error) { + this.logger.error(`[deleteOidcIssuer] - error: ${JSON.stringify(error)}`); + throw error; + } + } + + async createTemplate( + issuerId: string, + data: Omit + ): Promise { + try { + return await this.prisma.credential_templates.create({ + data: { + ...data, + issuerId + } + }); + } catch (error) { + this.logger.error(`Error in createTemplate: ${error.message}`); + throw error; + } + } + + async getTemplateById(templateId: string): Promise { + try { + return await this.prisma.credential_templates.findUnique({ + where: { id: templateId } + }); + } catch (error) { + this.logger.error(`Error in getTemplateById: ${error.message}`); + throw error; + } + } + + async getTemplateByIds(templateIds: string[], issuerId: string): Promise { + try { + // Early return if empty input (avoids full table scan if someone passes []) + if (!Array.isArray(templateIds) || 0 === templateIds.length) { + return []; + } + + this.logger.debug(`getTemplateByIds templateIds=${JSON.stringify(templateIds)} issuerId=${issuerId}`); + + return await this.prisma.credential_templates.findMany({ + where: { + id: { in: templateIds }, + issuerId + } + }); + } catch (error) { + this.logger.error(`Error in getTemplateByIds: ${error?.message}`, error?.stack); + throw error; + } + } + + async getTemplateByNameForIssuer(name: string, issuerId: string): Promise { + try { + return await this.prisma.credential_templates.findMany({ + where: { + issuerId, + name: { + equals: name, + mode: 'insensitive' + } + } + }); + } catch (error) { + this.logger.error(`Error in getTemplateByNameForIssuer: ${error.message}`); + throw error; + } + } + + async getTemplatesByIssuerId(issuerId: string): Promise { + try { + return await this.prisma.credential_templates.findMany({ + where: { issuerId }, + orderBy: { + createdAt: 'desc' + } + }); + } catch (error) { + this.logger.error(`Error in getTemplatesByIssuer: ${error.message}`); + throw error; + } + } + + async updateTemplate(templateId: string, data: Partial): Promise { + try { + return await this.prisma.credential_templates.update({ + where: { id: templateId }, + data + }); + } catch (error) { + this.logger.error(`Error in updateTemplate: ${error.message}`); + throw error; + } + } + + async deleteTemplate(templateId: string): Promise { + try { + return await this.prisma.credential_templates.delete({ + where: { id: templateId } + }); + } catch (error) { + this.logger.error(`Error in deleteTemplate: ${error.message}`); + throw error; + } + } +} diff --git a/apps/issuance/src/oidc-issuance.service.ts b/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts similarity index 73% rename from apps/issuance/src/oidc-issuance.service.ts rename to apps/oid4vc-issuance/src/oid4vc-issuance.service.ts index 1059ff8a6..c796249b3 100644 --- a/apps/issuance/src/oidc-issuance.service.ts +++ b/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts @@ -17,7 +17,7 @@ import { NotFoundException, Scope } from '@nestjs/common'; -import { IssuanceRepository } from './issuance.repository'; +import { Oid4vcIssuanceRepository } from './oid4vc-issuance.repository'; import { CommonConstants } from '@credebl/common/common.constant'; import { ResponseMessages } from '@credebl/common/response-messages'; import { ClientProxy, RpcException } from '@nestjs/microservices'; @@ -31,8 +31,8 @@ import { IssuerMetadata, IssuerResponse, IssuerUpdation -} from '../interfaces/oidc-issuance.interfaces'; -import { CreateCredentialTemplate, UpdateCredentialTemplate } from '../interfaces/oidc-template.interface'; +} from '../interfaces/oid4vc-issuance.interfaces'; +import { CreateCredentialTemplate, UpdateCredentialTemplate } from '../interfaces/oid4vc-template.interfaces'; import { accessTokenSignerKeyType, batchCredentialIssuanceDefault, @@ -42,22 +42,25 @@ import { import { buildCredentialConfigurationsSupported, buildIssuerPayload, - extractTemplateIds + encodeIssuerPublicId, + extractTemplateIds, + normalizeJson } from '../libs/helpers/issuer.metadata'; import { CreateOidcCredentialOffer, + CredentialPayload, GetAllCredentialOffer, SignerMethodOption, SignerOption, UpdateCredentialRequest -} from '../interfaces/oidc-issuer-sessions.interfaces'; +} from '../interfaces/oid4vc-issuer-sessions.interfaces'; import { BadRequestErrorDto } from 'apps/api-gateway/src/dtos/bad-request-error.dto'; import { buildCredentialOfferPayload, buildCredentialOfferUrl, - buildUpdateCredentialOfferPayload, CredentialOfferPayload } from '../libs/helpers/credential-sessions.builder'; +import { context } from '@opentelemetry/api'; type CredentialDisplayItem = { logo?: { uri: string; alt_text?: string }; @@ -70,22 +73,22 @@ type Appearance = { display: CredentialDisplayItem[]; }; @Injectable() -export class OIDCIssuanceService { +export class Oid4vcIssuanceService { private readonly logger = new Logger('IssueCredentialService'); constructor( @Inject('NATS_CLIENT') private readonly issuanceServiceProxy: ClientProxy, - private readonly issuanceRepository: IssuanceRepository + private readonly oid4vcIssuanceRepository: Oid4vcIssuanceRepository ) {} async oidcIssuerCreate(issuerCreation: IssuerCreation, orgId: string, userDetails: user): Promise { try { const { issuerId, batchCredentialIssuanceSize } = issuerCreation; - const agentDetails = await this.issuanceRepository.getAgentEndPoint(orgId); + const agentDetails = await this.oid4vcIssuanceRepository.getAgentEndPoint(orgId); if (!agentDetails) { throw new NotFoundException(ResponseMessages.issuance.error.agentEndPointNotFound); } const { agentEndPoint, id: orgAgentId, orgAgentTypeId } = agentDetails; - const orgAgentType = await this.issuanceRepository.getOrgAgentType(orgAgentTypeId); + const orgAgentType = await this.oid4vcIssuanceRepository.getOrgAgentType(orgAgentTypeId); if (!orgAgentType) { throw new NotFoundException(ResponseMessages.issuance.error.orgAgentTypeNotFound); } @@ -126,13 +129,13 @@ export class OIDCIssuanceService { orgAgentId, batchCredentialIssuanceSize: issuerCreation?.batchCredentialIssuanceSize }; - const addOidcIssuerDetails = await this.issuanceRepository.addOidcIssuerDetails( + const addOidcIssuerDetails = await this.oid4vcIssuanceRepository.addOidcIssuerDetails( issuerMetadata, issuerCreation?.display ); if (!addOidcIssuerDetails) { - throw new InternalServerErrorException('Error in adding OIDC Issuer details in DB'); + throw new InternalServerErrorException('Error in adding OID4VC Issuer details in DB'); } return addOidcIssuerDetails; } catch (error) { @@ -143,27 +146,29 @@ export class OIDCIssuanceService { async oidcIssuerUpdate(issuerUpdationConfig: IssuerUpdation, orgId: string, userDetails: user): Promise { try { - const getIssuerDetails = await this.issuanceRepository.getOidcIssuerDetailsById(issuerUpdationConfig.issuerId); + const getIssuerDetails = await this.oid4vcIssuanceRepository.getOidcIssuerDetailsById( + issuerUpdationConfig.issuerId + ); if (!getIssuerDetails) { throw new NotFoundException(ResponseMessages.oidcIssuer.error.notFound); } - const agentDetails = await this.issuanceRepository.getAgentEndPoint(orgId); + const agentDetails = await this.oid4vcIssuanceRepository.getAgentEndPoint(orgId); if (!agentDetails) { throw new NotFoundException(ResponseMessages.issuance.error.agentEndPointNotFound); } - const { agentEndPoint, id: orgAgentId, orgAgentTypeId } = agentDetails; - const orgAgentType = await this.issuanceRepository.getOrgAgentType(orgAgentTypeId); + const { agentEndPoint, orgAgentTypeId } = agentDetails; + const orgAgentType = await this.oid4vcIssuanceRepository.getOrgAgentType(orgAgentTypeId); if (!orgAgentType) { throw new NotFoundException(ResponseMessages.issuance.error.orgAgentTypeNotFound); } - const addOidcIssuerDetails = await this.issuanceRepository.updateOidcIssuerDetails( + const addOidcIssuerDetails = await this.oid4vcIssuanceRepository.updateOidcIssuerDetails( userDetails.id, issuerUpdationConfig ); if (!addOidcIssuerDetails) { - throw new InternalServerErrorException('Error in updating OIDC Issuer details in DB'); + throw new InternalServerErrorException('Error in updating OID4VC Issuer details in DB'); } const url = await getAgentUrl( @@ -172,6 +177,7 @@ export class OIDCIssuanceService { getIssuerDetails.publicIssuerId ); const issuerConfig = await this.buildOidcIssuerConfig(issuerUpdationConfig.issuerId); + console.log('This is the issuerConfig:', JSON.stringify(issuerConfig, null, 2)); const updatedIssuer = await this._createOIDCTemplate(issuerConfig, url, orgId); if (updatedIssuer?.response?.statusCode && 200 !== updatedIssuer?.response?.statusCode) { throw new InternalServerErrorException( @@ -188,40 +194,45 @@ export class OIDCIssuanceService { async oidcIssuerGetById(issuerId: string, orgId: string): Promise { try { - const getIssuerDetails = await this.issuanceRepository.getOidcIssuerDetailsById(issuerId); + const getIssuerDetails = await this.oid4vcIssuanceRepository.getOidcIssuerDetailsById(issuerId); + console.log('This is the getIssuerDetails:', JSON.stringify(getIssuerDetails, null, 2)); if (!getIssuerDetails && getIssuerDetails.publicIssuerId) { throw new NotFoundException(ResponseMessages.oidcIssuer.error.notFound); } - const agentDetails = await this.issuanceRepository.getAgentEndPoint(orgId); + const agentDetails = await this.oid4vcIssuanceRepository.getAgentEndPoint(orgId); if (!agentDetails) { throw new NotFoundException(ResponseMessages.issuance.error.agentEndPointNotFound); } - const url = await getAgentUrl( - agentDetails?.agentEndPoint, - CommonConstants.OIDC_ISSUER_BY_ID, - getIssuerDetails?.publicIssuerId - ); + console.log('This is the agentDetails:', getIssuerDetails?.publicIssuerId); + const encodedId = encodeIssuerPublicId(getIssuerDetails?.publicIssuerId); + const url = await getAgentUrl(agentDetails?.agentEndPoint, CommonConstants.OIDC_ISSUER_BY_ID, encodedId); + console.log('This is the oidcIssuerGetById url:', url); const issuerDetailsRaw = await this._oidcGetIssuerById(url, orgId); - + console.log('This is the issuerDetailsRaw:', JSON.stringify(issuerDetailsRaw, null, 2)); if (!issuerDetailsRaw) { throw new InternalServerErrorException(`Error from agent while getting issuer`); } - - // response is a string → parse it into IssuerResponse const issuerDetails = { - response: JSON.parse(issuerDetailsRaw.response) as IssuerResponse + response: normalizeJson(issuerDetailsRaw.response) }; return issuerDetails.response; } catch (error) { - this.logger.error(`[oidcIssuerGetById] - error in oidcIssuerGetById issuance records: ${JSON.stringify(error)}`); - throw new RpcException(error?.response ?? error); + const errorStack = error?.status?.message?.error; + if (errorStack) { + throw new RpcException({ + message: errorStack?.reason ? errorStack?.reason : errorStack?.message, + statusCode: error?.status?.code + }); + } else { + throw new RpcException(error.response ? error.response : error); + } } } async oidcIssuers(orgId: string): Promise { try { - const agentDetails = await this.issuanceRepository.getAgentEndPoint(orgId); + const agentDetails = await this.oid4vcIssuanceRepository.getAgentEndPoint(orgId); if (!agentDetails?.agentEndPoint) { throw new NotFoundException(ResponseMessages.issuance.error.agentEndPointNotFound); } @@ -239,7 +250,6 @@ export class OIDCIssuanceService { if (!Array.isArray(response)) { throw new InternalServerErrorException('Invalid issuer payload from agent'); } - return response; } catch (error: any) { const msg = error?.message ?? 'unknown error'; @@ -250,34 +260,30 @@ export class OIDCIssuanceService { async deleteOidcIssuer(orgId: string, userDetails: user, issuerId: string) { try { - const deleteOidcIssuer = await this.issuanceRepository.deleteOidcIssuer(issuerId); + const deleteOidcIssuer = await this.oid4vcIssuanceRepository.deleteOidcIssuer(issuerId); if (!deleteOidcIssuer) { - throw new NotFoundException(ResponseMessages.oidcTemplate.error.deleteTemplate); + throw new NotFoundException(ResponseMessages.oidcIssuer.error.deleteFailed); } - const issuerRecordId = await this.oidcIssuerGetById(issuerId, orgId); if (!issuerRecordId.id) { throw new NotFoundException(ResponseMessages.oidcIssuer.error.notFound); } - const agentDetails = await this.issuanceRepository.getAgentEndPoint(orgId); + const agentDetails = await this.oid4vcIssuanceRepository.getAgentEndPoint(orgId); if (!agentDetails) { throw new NotFoundException(ResponseMessages.issuance.error.agentEndPointNotFound); } const { agentEndPoint } = agentDetails; - const issuerDetails = await this.issuanceRepository.getOidcIssuerDetailsById(issuerId); - if (!issuerDetails) { - throw new NotFoundException(ResponseMessages.oidcTemplate.error.issuerDetailsNotFound); - } const url = await getAgentUrl(agentEndPoint, CommonConstants.OIDC_ISSUER_DELETE, issuerRecordId.id); - const createTemplateOnAgent = await this._deleteOidcIssuer(url, orgId); if (!createTemplateOnAgent) { throw new NotFoundException(ResponseMessages.issuance.error.agentEndPointNotFound); } return deleteOidcIssuer; } catch (error) { - this.logger.error(`[deleteTemplate] - error: ${JSON.stringify(error)}`); - throw new RpcException(error.response ?? error); + if ('PrismaClientKnownRequestError' === error.name) { + throw new BadRequestException(error.meta?.cause ?? ResponseMessages.oidcIssuer.error.deleteFailed); + } + throw new Error(error.response ? error.response : error); } } @@ -288,7 +294,7 @@ export class OIDCIssuanceService { ): Promise { try { const { name, description, format, canBeRevoked, attributes, appearance, signerOption } = CredentialTemplate; - const checkNameExist = await this.issuanceRepository.getTemplateByNameForIssuer(name, issuerId); + const checkNameExist = await this.oid4vcIssuanceRepository.getTemplateByNameForIssuer(name, issuerId); if (0 < checkNameExist.length) { throw new ConflictException(ResponseMessages.oidcTemplate.error.templateNameAlreadyExist); } @@ -303,17 +309,17 @@ export class OIDCIssuanceService { signerOption }; // Persist in DB - const createdTemplate = await this.issuanceRepository.createTemplate(issuerId, metadata); + const createdTemplate = await this.oid4vcIssuanceRepository.createTemplate(issuerId, metadata); if (!createdTemplate) { throw new InternalServerErrorException(ResponseMessages.oidcTemplate.error.createFailed); } const issuerTemplateConfig = await this.buildOidcIssuerConfig(issuerId); - const agentDetails = await this.issuanceRepository.getAgentEndPoint(orgId); + const agentDetails = await this.oid4vcIssuanceRepository.getAgentEndPoint(orgId); if (!agentDetails) { throw new NotFoundException(ResponseMessages.issuance.error.agentEndPointNotFound); } const { agentEndPoint } = agentDetails; - const issuerDetails = await this.issuanceRepository.getOidcIssuerDetailsById(issuerId); + const issuerDetails = await this.oid4vcIssuanceRepository.getOidcIssuerDetailsById(issuerId); if (!issuerDetails) { throw new NotFoundException(ResponseMessages.oidcTemplate.error.issuerDetailsNotFound); } @@ -336,12 +342,12 @@ export class OIDCIssuanceService { issuerId: string ): Promise { try { - const template = await this.issuanceRepository.getTemplateById(templateId); + const template = await this.oid4vcIssuanceRepository.getTemplateById(templateId); if (!template) { throw new NotFoundException(ResponseMessages.oidcTemplate.error.notFound); } if (updateCredentialTemplate.name) { - const checkNameExist = await this.issuanceRepository.getTemplateByNameForIssuer( + const checkNameExist = await this.oid4vcIssuanceRepository.getTemplateByNameForIssuer( updateCredentialTemplate.name, issuerId ); @@ -365,19 +371,19 @@ export class OIDCIssuanceService { ...(issuerId ? { issuerId } : {}) }; - const updatedTemplate = await this.issuanceRepository.updateTemplate(templateId, payload); + const updatedTemplate = await this.oid4vcIssuanceRepository.updateTemplate(templateId, payload); - const templates = await this.issuanceRepository.getTemplatesByIssuerId(issuerId); + const templates = await this.oid4vcIssuanceRepository.getTemplatesByIssuerId(issuerId); if (!templates || 0 === templates.length) { throw new NotFoundException(ResponseMessages.issuance.error.notFound); } const issuerTemplateConfig = await this.buildOidcIssuerConfig(issuerId); - const agentDetails = await this.issuanceRepository.getAgentEndPoint(orgId); + const agentDetails = await this.oid4vcIssuanceRepository.getAgentEndPoint(orgId); if (!agentDetails) { throw new NotFoundException(ResponseMessages.issuance.error.agentEndPointNotFound); } const { agentEndPoint } = agentDetails; - const issuerDetails = await this.issuanceRepository.getOidcIssuerDetailsById(issuerId); + const issuerDetails = await this.oid4vcIssuanceRepository.getOidcIssuerDetailsById(issuerId); if (!issuerDetails) { throw new NotFoundException(ResponseMessages.oidcTemplate.error.issuerDetailsNotFound); } @@ -398,22 +404,22 @@ export class OIDCIssuanceService { // eslint-disable-next-line @typescript-eslint/no-explicit-any async deleteTemplate(templateId: string, orgId: string, userDetails: user, issuerId: string): Promise { try { - const template = await this.issuanceRepository.getTemplateById(templateId); + const template = await this.oid4vcIssuanceRepository.getTemplateById(templateId); if (!template) { throw new NotFoundException(ResponseMessages.oidcTemplate.error.notFound); } - const deleteTemplate = await this.issuanceRepository.deleteTemplate(templateId); + const deleteTemplate = await this.oid4vcIssuanceRepository.deleteTemplate(templateId); if (!deleteTemplate) { throw new NotFoundException(ResponseMessages.oidcTemplate.error.deleteTemplate); } const issuerTemplateConfig = await this.buildOidcIssuerConfig(issuerId); - const agentDetails = await this.issuanceRepository.getAgentEndPoint(orgId); + const agentDetails = await this.oid4vcIssuanceRepository.getAgentEndPoint(orgId); if (!agentDetails) { throw new NotFoundException(ResponseMessages.issuance.error.agentEndPointNotFound); } const { agentEndPoint } = agentDetails; - const issuerDetails = await this.issuanceRepository.getOidcIssuerDetailsById(issuerId); + const issuerDetails = await this.oid4vcIssuanceRepository.getOidcIssuerDetailsById(issuerId); if (!issuerDetails) { throw new NotFoundException(ResponseMessages.oidcTemplate.error.issuerDetailsNotFound); } @@ -430,15 +436,16 @@ export class OIDCIssuanceService { } } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - async findByIdTemplate(templateId: string, orgId: string, userDetails: user, issuerId: string): Promise { + async findByIdTemplate(templateId: string, orgId: string): Promise { try { - const template = await this.issuanceRepository.getTemplateById(templateId); + if (!orgId || !templateId) { + throw new BadRequestException(ResponseMessages.oidcTemplate.error.invalidId); + } + const template = await this.oid4vcIssuanceRepository.getTemplateById(templateId); if (!template) { throw new NotFoundException(ResponseMessages.oidcTemplate.error.notFound); } - - return { message: ResponseMessages.oidcTemplate.success.fetch, data: template }; + return template; } catch (error) { this.logger.error(`[findByIdTemplate] - error: ${JSON.stringify(error)}`); throw new RpcException(error.response ?? error); @@ -456,26 +463,36 @@ export class OIDCIssuanceService { if (!filterTemplateIds) { throw new BadRequestException('Please provide a valid id'); } - const getAllOfferTemplates = await this.issuanceRepository.getTemplateByIds(filterTemplateIds, issuerId); + const getAllOfferTemplates = await this.oid4vcIssuanceRepository.getTemplateByIds(filterTemplateIds, issuerId); if (!getAllOfferTemplates) { throw new NotFoundException('No templates found for the issuer'); } - const agentDetails = await this.issuanceRepository.getAgentEndPoint(orgId); + const agentDetails = await this.oid4vcIssuanceRepository.getAgentEndPoint(orgId); //TDOD: signerOption should be under credentials change this with x509 support - const signerOption: SignerOption = { - method: SignerMethodOption.DID, - did: agentDetails.orgDid - }; + + //TDOD: signerOption should be under credentials change this with x509 support + const signerOptions: SignerOption[] = []; + getAllOfferTemplates.forEach((template) => { + if (template.signerOption === SignerMethodOption.DID) { + signerOptions.push({ + method: SignerMethodOption.DID, + did: agentDetails.orgDid + }); + } + }); + //TODO: Implement x509 support and discuss with team const buildOidcCredentialOffer: CredentialOfferPayload = buildCredentialOfferPayload( createOidcCredentialOffer, getAllOfferTemplates, - signerOption + signerOptions ); + console.log('This is the buildOidcCredentialOffer:', JSON.stringify(buildOidcCredentialOffer, null, 2)); + if (!buildOidcCredentialOffer) { - throw new BadRequestException('Error while creating oidc credential offer'); + throw new BadRequestException('Error while creating oid4vc credential offer'); } - const issuerDetails = await this.issuanceRepository.getOidcIssuerDetailsById(issuerId); + const issuerDetails = await this.oid4vcIssuanceRepository.getOidcIssuerDetailsById(issuerId); if (!issuerDetails) { throw new NotFoundException(ResponseMessages.oidcTemplate.error.issuerDetailsNotFound); } @@ -524,7 +541,7 @@ export class OIDCIssuanceService { // getAllOfferTemplates // ); // if (!buildOidcUpdateCredentialOffer) { - // throw new BadRequestException('Error while creating oidc credential offer'); + // throw new BadRequestException('Error while creating oid4vc credential offer'); // } // const agentDetails = await this.issuanceRepository.getAgentEndPoint(orgId); // if (!agentDetails) { @@ -620,8 +637,8 @@ export class OIDCIssuanceService { // eslint-disable-next-line @typescript-eslint/explicit-function-return-type async buildOidcIssuerConfig(issuerId: string) { try { - const issuerDetails = await this.issuanceRepository.getOidcIssuerDetailsById(issuerId); - const templates = await this.issuanceRepository.getTemplatesByIssuerId(issuerId); + const issuerDetails = await this.oid4vcIssuanceRepository.getOidcIssuerDetailsById(issuerId); + const templates = await this.oid4vcIssuanceRepository.getTemplatesByIssuerId(issuerId); const credentialConfigurationsSupported = buildCredentialConfigurationsSupported(templates); @@ -632,11 +649,12 @@ export class OIDCIssuanceService { } } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - async findAllTemplate(orgId: string, userDetails: user, issuerId: string): Promise { + async findAllTemplate(orgId: string, issuerId: string): Promise { try { - const templates = await this.issuanceRepository.getTemplatesByIssuerId(issuerId); - return { message: ResponseMessages.oidcTemplate.success.fetch, data: templates }; + if (!orgId || !issuerId) { + throw new BadRequestException(ResponseMessages.oidcTemplate.error.invalidId); + } + return this.oid4vcIssuanceRepository.getTemplatesByIssuerId(issuerId); } catch (error) { this.logger.error(`[findAllTemplate] - error: ${JSON.stringify(error)}`); throw new RpcException(error.response ?? error); @@ -646,67 +664,73 @@ export class OIDCIssuanceService { // eslint-disable-next-line @typescript-eslint/no-explicit-any async _createOIDCIssuer(issuerCreation, url: string, orgId: string): Promise { try { - const pattern = { cmd: 'agent-create-oidc-issuer' }; + const pattern = { cmd: 'agent-create-oid4vc-issuer' }; const payload: IAgentOIDCIssuerCreate = { issuerCreation, url, orgId }; return this.natsCall(pattern, payload); } catch (error) { - this.logger.error(`[_createOIDCIssuer] [NATS call]- error in create OIDC Issuer : ${JSON.stringify(error)}`); + this.logger.error(`[_createOIDCIssuer] [NATS call]- error in create OID4VC Issuer : ${JSON.stringify(error)}`); throw error; } } async _createOIDCTemplate(templatePayload, url: string, orgId: string): Promise { try { - const pattern = { cmd: 'agent-create-oidc-template' }; + const pattern = { cmd: 'agent-create-oid4vc-template' }; const payload = { templatePayload, url, orgId }; return this.natsCall(pattern, payload); } catch (error) { - this.logger.error(`[_createOIDCTemplate] [NATS call]- error in create OIDC Template : ${JSON.stringify(error)}`); + this.logger.error( + `[_createOIDCTemplate] [NATS call]- error in create OID4VC Template : ${JSON.stringify(error)}` + ); throw error; } } async _deleteOidcIssuer(url: string, orgId: string): Promise { try { - const pattern = { cmd: 'delete-oidc-issuer' }; + const pattern = { cmd: 'delete-oid4vc-issuer' }; const payload = { url, orgId }; return this.natsCall(pattern, payload); } catch (error) { - this.logger.error(`[_createOIDCTemplate] [NATS call]- error in create OIDC Template : ${JSON.stringify(error)}`); + this.logger.error( + `[_createOIDCTemplate] [NATS call]- error in create OID4VC Template : ${JSON.stringify(error)}` + ); throw error; } } async _oidcGetIssuerById(url: string, orgId: string) { try { - const pattern = { cmd: 'oidc-get-issuer-by-id' }; + const pattern = { cmd: 'oid4vc-get-issuer-by-id' }; const payload = { url, orgId }; return this.natsCall(pattern, payload); } catch (error) { - this.logger.error(`[_oidcGetIssuerById] [NATS call]- error in oidc get issuer by id : ${JSON.stringify(error)}`); + this.logger.error( + `[_oidcGetIssuerById] [NATS call]- error in oid4vc get issuer by id : ${JSON.stringify(error)}` + ); throw error; } } async _oidcGetIssuers(url: string, orgId: string) { try { - const pattern = { cmd: 'oidc-get-issuers-agent-service' }; + const pattern = { cmd: 'oid4vc-get-issuers-agent-service' }; const payload = { url, orgId }; return this.natsCall(pattern, payload); } catch (error) { - this.logger.error(`[_oidcGetIssuers] [NATS call]- error in oidc get issuers : ${JSON.stringify(error)}`); + this.logger.error(`[_oidcGetIssuers] [NATS call]- error in oid4vc get issuers : ${JSON.stringify(error)}`); throw error; } } async _oidcCreateCredentialOffer(credentialPayload: CredentialOfferPayload, url: string, orgId: string) { try { - const pattern = { cmd: 'agent-service-oidc-create-credential-offer' }; + const pattern = { cmd: 'agent-service-oid4vc-create-credential-offer' }; const payload = { credentialPayload, url, orgId }; return this.natsCall(pattern, payload); } catch (error) { this.logger.error( - `[_oidcCreateCredentialOffer] [NATS call]- error in oidc create credential offer : ${JSON.stringify(error)}` + `[_oidcCreateCredentialOffer] [NATS call]- error in oid4vc create credential offer : ${JSON.stringify(error)}` ); throw error; } @@ -714,12 +738,12 @@ export class OIDCIssuanceService { async _oidcUpdateCredentialOffer(issuanceMetadata, url: string, orgId: string) { try { - const pattern = { cmd: 'agent-service-oidc-update-credential-offer' }; + const pattern = { cmd: 'agent-service-oid4vc-update-credential-offer' }; const payload = { issuanceMetadata, url, orgId }; return this.natsCall(pattern, payload); } catch (error) { this.logger.error( - `[_oidcUpdateCredentialOffer] [NATS call]- error in oidc update credential offer : ${JSON.stringify(error)}` + `[_oidcUpdateCredentialOffer] [NATS call]- error in oid4vc update credential offer : ${JSON.stringify(error)}` ); throw error; } @@ -727,12 +751,12 @@ export class OIDCIssuanceService { async _oidcGetCredentialOfferById(url: string, orgId: string) { try { - const pattern = { cmd: 'agent-service-oidc-get-credential-offer-by-id' }; + const pattern = { cmd: 'agent-service-oid4vc-get-credential-offer-by-id' }; const payload = { url, orgId }; return this.natsCall(pattern, payload); } catch (error) { this.logger.error( - `[_oidcGetCredentialOfferById] [NATS call]- error in oidc get credential offer by id : ${JSON.stringify(error)}` + `[_oidcGetCredentialOfferById] [NATS call]- error in oid4vc get credential offer by id : ${JSON.stringify(error)}` ); throw error; } @@ -740,12 +764,12 @@ export class OIDCIssuanceService { async _oidcGetCredentialOffers(url: string, orgId: string) { try { - const pattern = { cmd: 'agent-service-oidc-get-credential-offers' }; + const pattern = { cmd: 'agent-service-oid4vc-get-credential-offers' }; const payload = { url, orgId }; return this.natsCall(pattern, payload); } catch (error) { this.logger.error( - `[_oidcGetCredentialOffers] [NATS call]- error in oidc get credential offers : ${JSON.stringify(error)}` + `[_oidcGetCredentialOffers] [NATS call]- error in oid4vc get credential offers : ${JSON.stringify(error)}` ); throw error; } @@ -753,12 +777,12 @@ export class OIDCIssuanceService { async _oidcDeleteCredentialOffer(url: string, orgId: string) { try { - const pattern = { cmd: 'agent-service-oidc-delete-credential-offer' }; + const pattern = { cmd: 'agent-service-oid4vc-delete-credential-offer' }; const payload = { url, orgId }; return this.natsCall(pattern, payload); } catch (error) { this.logger.error( - `[_oidcDeleteCredentialOffer] [NATS call]- error in oidc delete credential offer : ${JSON.stringify(error)}` + `[_oidcDeleteCredentialOffer] [NATS call]- error in oid4vc delete credential offer : ${JSON.stringify(error)}` ); throw error; } @@ -796,7 +820,7 @@ export class OIDCIssuanceService { } async getAgentEndpoint(orgId: string): Promise { - const agentDetails = await this.issuanceRepository.getAgentEndPoint(orgId); + const agentDetails = await this.oid4vcIssuanceRepository.getAgentEndPoint(orgId); if (!agentDetails) { throw new NotFoundException(ResponseMessages.issuance.error.agentEndPointNotFound); @@ -808,4 +832,34 @@ export class OIDCIssuanceService { return agentDetails.agentEndPoint; } + + async storeOidcCredentialWebhook(CredentialOfferWebhookPayload): Promise { + try { + console.log('Storing OID4VC Credential Webhook:', CredentialOfferWebhookPayload); + const { credentialOfferId, state, id, contextCorrelationId } = CredentialOfferWebhookPayload; + let orgId: string; + if ('default' !== contextCorrelationId) { + const getOrganizationId = await this.oid4vcIssuanceRepository.getOrganizationByTenantId(contextCorrelationId); + orgId = getOrganizationId?.orgId; + } else { + orgId = id; + } + + const credentialPayload = { + orgId, + offerId: id, + credentialOfferId, + state, + contextCorrelationId + }; + + const agentDetails = await this.oid4vcIssuanceRepository.storeOidcCredentialDetails(credentialPayload); + return agentDetails; + } catch (error) { + this.logger.error( + `[getIssueCredentialsbyCredentialRecordId] - error in get credentials : ${JSON.stringify(error)}` + ); + throw new RpcException(error.response ? error.response : error); + } + } } diff --git a/apps/oid4vc-issuance/test/app.e2e-spec.ts b/apps/oid4vc-issuance/test/app.e2e-spec.ts new file mode 100644 index 000000000..37bf09beb --- /dev/null +++ b/apps/oid4vc-issuance/test/app.e2e-spec.ts @@ -0,0 +1,19 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication } from '@nestjs/common'; +import * as request from 'supertest'; +import { Oid4vcIssuanceModule } from './../src/oid4vc-issuance.module'; + +describe('Oid4vcIssuanceController (e2e)', () => { + let app: INestApplication; + + beforeEach(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [Oid4vcIssuanceModule] + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + }); + + it('/ (GET)', () => request(app.getHttpServer()).get('/').expect(200).expect('Hello World!')); +}); diff --git a/apps/oid4vc-issuance/test/jest-e2e.json b/apps/oid4vc-issuance/test/jest-e2e.json new file mode 100644 index 000000000..e9d912f3e --- /dev/null +++ b/apps/oid4vc-issuance/test/jest-e2e.json @@ -0,0 +1,9 @@ +{ + "moduleFileExtensions": ["js", "json", "ts"], + "rootDir": ".", + "testEnvironment": "node", + "testRegex": ".e2e-spec.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + } +} diff --git a/apps/oid4vc-issuance/tsconfig.app.json b/apps/oid4vc-issuance/tsconfig.app.json new file mode 100644 index 000000000..b5f8c422e --- /dev/null +++ b/apps/oid4vc-issuance/tsconfig.app.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "declaration": false, + "outDir": "../../dist/apps/oid4vc-issuance" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] +} diff --git a/libs/common/src/common.constant.ts b/libs/common/src/common.constant.ts index 323f602c8..cd28289d3 100644 --- a/libs/common/src/common.constant.ts +++ b/libs/common/src/common.constant.ts @@ -117,7 +117,7 @@ export enum CommonConstants { // CREATE KEYS CREATE_POLYGON_SECP256k1_KEY = '/polygon/create-keys', - // OIDC URLs + // OID4VC URLs URL_OIDC_ISSUER_CREATE = '/openid4vc/issuer', /* eslint-disable @typescript-eslint/naming-convention, @typescript-eslint/explicit-function-return-type, @typescript-eslint/explicit-module-boundary-types, camelcase, @typescript-eslint/no-duplicate-enum-values */ URL_OIDC_GET_ISSUES = '/openid4vc/issuer', @@ -373,6 +373,7 @@ export enum CommonConstants { NOTIFICATION_SERVICE = 'notification', GEO_LOCATION_SERVICE = 'geo-location', CLOUD_WALLET_SERVICE = 'cloud-wallet', + OIDC4VC_ISSUANCE_SERVICE = 'oid4vc-issuance', ACCEPT_OFFER = '/didcomm/credentials/accept-offer', SEED_LENGTH = 32, @@ -394,17 +395,17 @@ export enum CommonConstants { SEND_QUESTION = 'send-question', SEND_BASIC_MESSAGE = 'send-basic-message', - // OIDC - OIDC_ISSUER_CREATE = 'create-oidc-issuer', - OIDC_ISSUER_DELETE = 'delete-oidc-issuer', - OIDC_GET_ALL_ISSUERS = 'get-all-oidc-issuers', + // OID4VC + OIDC_ISSUER_CREATE = 'create-oid4vc-issuer', + OIDC_ISSUER_DELETE = 'delete-oid4vc-issuer', + OIDC_GET_ALL_ISSUERS = 'get-all-oid4vc-issuers', OIDC_ISSUER_BY_ID = 'get-issuer-by-id', - OIDC_ISSUER_TEMPLATE = 'create-oidc-template', - OIDC_ISSUER_SESSIONS_CREDENTIAL_OFFER = 'create-oidc-credential-offer', - OIDC_ISSUER_SESSIONS_UPDATE_OFFER = 'update-oidc-credential-offer', - OIDC_ISSUER_SESSIONS_BY_ID = 'get-oidc-session-by-id', - OIDC_ISSUER_SESSIONS = 'get-oidc-sessions', - OIDC_DELETE_CREDENTIAL_OFFER = 'delete-oidc-credential-offer' + OIDC_ISSUER_TEMPLATE = 'create-oid4vc-template', + OIDC_ISSUER_SESSIONS_CREDENTIAL_OFFER = 'create-oid4vc-credential-offer', + OIDC_ISSUER_SESSIONS_UPDATE_OFFER = 'update-oid4vc-credential-offer', + OIDC_ISSUER_SESSIONS_BY_ID = 'get-oid4vc-session-by-id', + OIDC_ISSUER_SESSIONS = 'get-oid4vc-sessions', + OIDC_DELETE_CREDENTIAL_OFFER = 'delete-oid4vc-credential-offer' } export const MICRO_SERVICE_NAME = Symbol('MICRO_SERVICE_NAME'); export const ATTRIBUTE_NAME_REGEX = /\['(.*?)'\]/; diff --git a/libs/common/src/response-messages/index.ts b/libs/common/src/response-messages/index.ts index 8b66fc8f4..edae74634 100644 --- a/libs/common/src/response-messages/index.ts +++ b/libs/common/src/response-messages/index.ts @@ -499,34 +499,35 @@ export const ResponseMessages = { }, oidcIssuer: { success: { - create: 'OIDC issuer created successfully.', - update: 'OIDC issuer updated successfully.', - delete: 'OIDC issuer deleted successfully.', - fetch: 'OIDC issuer(s) fetched successfully.', + create: 'OID4VC issuer created successfully.', + update: 'OID4VC issuer updated successfully.', + delete: 'OID4VC issuer deleted successfully.', + fetch: 'OID4VC issuer(s) fetched successfully.', issuerConfig: 'Issuer config details created successfully', issuerConfigUpdate: 'Issuer config details updated successfully' }, error: { - notFound: 'OIDC issuer not found.', - invalidId: 'Invalid OIDC issuer ID.', - createFailed: 'Failed to create OIDC issuer.', - updateFailed: 'Failed to update OIDC issuer.', - deleteFailed: 'Failed to delete OIDC issuer.' + notFound: 'OID4VC issuer not found.', + invalidId: 'Invalid OID4VC issuer ID.', + createFailed: 'Failed to create OID4VC issuer.', + updateFailed: 'Failed to update OID4VC issuer.', + deleteFailed: 'Failed to delete OID4VC issuer.' } }, oidcTemplate: { success: { - create: 'OIDC template created successfully.', - update: 'OIDC template updated successfully.', - delete: 'OIDC template deleted successfully.', - fetch: 'OIDC template(s) fetched successfully.' + create: 'OID4VC template created successfully.', + update: 'OID4VC template updated successfully.', + delete: 'OID4VC template deleted successfully.', + fetch: 'OID4VC template(s) fetched successfully.', + getById: 'OID4VC template details fetched successfully.' }, error: { - notFound: 'OIDC template not found.', - invalidId: 'Invalid OIDC template ID.', - createFailed: 'Failed to create OIDC template.', - updateFailed: 'Failed to update OIDC template.', - deleteFailed: 'Failed to delete OIDC template.', + notFound: 'OID4VC template not found.', + invalidId: 'Invalid OID4VC template ID.', + createFailed: 'Failed to create OID4VC template.', + updateFailed: 'Failed to update OID4VC template.', + deleteFailed: 'Failed to delete OID4VC template.', issuerDisplayNotFound: 'Issuer display not found.', issuerDetailsNotFound: 'Issuer details not found.', templateNameAlreadyExist: 'Template name already exists for this issuer.', @@ -535,16 +536,16 @@ export const ResponseMessages = { }, oidcIssuerSession: { success: { - create: 'OIDC Credential offer created successfully.', - getById: 'OIDC Credential offer details fetched successfully.', - getAll: 'OIDC Credential offers fetched successfully.', - update: 'OIDC Credential offer updated successfully.', - delete: 'OIDC Credential offer deleted successfully.' + create: 'OID4VC Credential offer created successfully.', + getById: 'OID4VC Credential offer details fetched successfully.', + getAll: 'OID4VC Credential offers fetched successfully.', + update: 'OID4VC Credential offer updated successfully.', + delete: 'OID4VC Credential offer deleted successfully.' }, error: { - errorCreateOffer: 'Error while creating OIDC credential offer on agent.', - errorUpdateOffer: 'Error while updating OIDC credential offer on agent.', - deleteFailed: 'Failed to delete OIDC credential offer.' + errorCreateOffer: 'Error while creating OID4VC credential offer on agent.', + errorUpdateOffer: 'Error while updating OID4VC credential offer on agent.', + deleteFailed: 'Failed to delete OID4VC credential offer.' } }, nats: { diff --git a/libs/prisma-service/prisma/schema.prisma b/libs/prisma-service/prisma/schema.prisma index e544c8e94..74bf6189f 100644 --- a/libs/prisma-service/prisma/schema.prisma +++ b/libs/prisma-service/prisma/schema.prisma @@ -149,6 +149,7 @@ model organisation { agent_invitations agent_invitations[] credential_definition credential_definition[] file_upload file_upload[] + oid4vc_credentials oid4vc_credentials[] } model org_invitations { @@ -581,6 +582,21 @@ model oidc_issuer { @@index([orgAgentId]) } +model oid4vc_credentials { + id String @id @default(uuid()) @db.Uuid + orgId String @db.Uuid + offerId String @unique + credentialOfferId String + state String + contextCorrelationId String + createdBy String @db.Uuid + createDateTime DateTime @default(now()) @db.Timestamptz(6) + lastChangedDateTime DateTime @default(now()) @db.Timestamptz(6) + lastChangedBy String @db.Uuid + + organisation organisation @relation(fields: [orgId], references: [id]) +} + enum SignerOption { did x509 @@ -602,4 +618,5 @@ model credential_templates { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt signerOption SignerOption -} \ No newline at end of file +} + diff --git a/nest-cli.json b/nest-cli.json index ee0cc7122..819afa8b7 100644 --- a/nest-cli.json +++ b/nest-cli.json @@ -286,6 +286,15 @@ "compilerOptions": { "tsConfigPath": "libs/logger/tsconfig.lib.json" } + }, + "oid4vc-issuance": { + "type": "application", + "root": "apps/oid4vc-issuance", + "entryFile": "main", + "sourceRoot": "apps/oid4vc-issuance/src", + "compilerOptions": { + "tsConfigPath": "apps/oid4vc-issuance/tsconfig.app.json" + } } } -} +} \ No newline at end of file diff --git a/package.json b/package.json index 214141f89..8839ec129 100644 --- a/package.json +++ b/package.json @@ -205,4 +205,4 @@ "engines": { "node": ">=18" } -} +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 74a5fa904..d0da5851f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -104,7 +104,8 @@ "@credebl/context/*": [ "libs/context/src/*" ] - } + }, + "baseUrl": "./" }, "exclude": [ "node_modules", From f8f08b4d545ce0e1fafa769818909829a0f99f88 Mon Sep 17 00:00:00 2001 From: Tipu_Singh Date: Thu, 25 Sep 2025 11:08:21 +0530 Subject: [PATCH 4/8] feat:removed logs Signed-off-by: Tipu_Singh --- apps/oid4vc-issuance/src/oid4vc-issuance.service.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts b/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts index c796249b3..4b9e1c91c 100644 --- a/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts +++ b/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts @@ -597,9 +597,7 @@ export class Oid4vcIssuanceService { try { const url = await getAgentUrl(await this.getAgentEndpoint(orgId), CommonConstants.OIDC_ISSUER_SESSIONS); const credentialOfferUrl = buildCredentialOfferUrl(url, getAllCredentialOffer); - console.log('This is credentialOfferUrl', credentialOfferUrl); const offers = await this._oidcGetCredentialOfferById(credentialOfferUrl, orgId); - console.log('This is offer', JSON.stringify(offers, null, 2)); if ('string' === typeof offers.response) { offers.response = JSON.parse(offers.response); } @@ -619,7 +617,6 @@ export class Oid4vcIssuanceService { CommonConstants.OIDC_DELETE_CREDENTIAL_OFFER, credentialId ); - console.log('This is the url:', url); const deletedCredentialOffer = await this._oidcDeleteCredentialOffer(url, orgId); if (!deletedCredentialOffer) { throw new NotFoundException(ResponseMessages.oidcIssuerSession.error.deleteFailed); From d2c84224b17c2a7a180399b5cec475a3ee923551 Mon Sep 17 00:00:00 2001 From: Tipu_Singh Date: Thu, 25 Sep 2025 11:12:08 +0530 Subject: [PATCH 5/8] refactor: removed duplicate code Signed-off-by: Tipu_Singh --- .../helpers/credential-sessions.builder.ts | 348 ------------------ apps/issuance/libs/helpers/issuer.metadata.ts | 244 ------------ 2 files changed, 592 deletions(-) delete mode 100644 apps/issuance/libs/helpers/credential-sessions.builder.ts delete mode 100644 apps/issuance/libs/helpers/issuer.metadata.ts diff --git a/apps/issuance/libs/helpers/credential-sessions.builder.ts b/apps/issuance/libs/helpers/credential-sessions.builder.ts deleted file mode 100644 index 5fb4997b5..000000000 --- a/apps/issuance/libs/helpers/credential-sessions.builder.ts +++ /dev/null @@ -1,348 +0,0 @@ -// builder/credential-offer.builder.ts -/* eslint-disable @typescript-eslint/naming-convention, @typescript-eslint/explicit-function-return-type, @typescript-eslint/explicit-module-boundary-types, camelcase */ -import { Prisma, credential_templates } from '@prisma/client'; -import { GetAllCredentialOffer } from 'apps/issuance/interfaces/oid4vc-issuer-sessions.interfaces'; - -/* ============================================================================ - Domain Types -============================================================================ */ - -type ValueType = 'string' | 'date' | 'number' | 'boolean' | string; - -interface TemplateAttribute { - display: { name: string; locale: string }[]; - mandatory: boolean; - value_type: ValueType; -} -type TemplateAttributes = Record; - -export enum CredentialFormat { - SdJwtVc = 'vc+sd-jwt', - Mdoc = 'mdoc' -} - -export enum SignerMethodOption { - DID = 'did', - X5C = 'x5c' -} - -export type DisclosureFrame = Record>; - -export interface CredentialRequestDtoLike { - /** maps to credential_templates.id (the template to use) */ - templateId: string; - /** per-template claims */ - payload: Record; - /** optional selective disclosure map */ - disclosureFrame?: DisclosureFrame; -} - -export interface CreateOidcCredentialOfferDtoLike { - credentials: CredentialRequestDtoLike[]; - - // Exactly one of the two must be provided (XOR) - preAuthorizedCodeFlowConfig?: { - txCode: { description?: string; length: number; input_mode: 'numeric' | 'text' | 'alphanumeric' }; - authorizationServerUrl: string; - }; - authorizationCodeFlowConfig?: { - authorizationServerUrl: string; - }; - - // NOTE: issuerId is intentionally NOT emitted in the final payload - publicIssuerId?: string; -} - -export interface ResolvedSignerOption { - method: 'did' | 'x5c'; - did?: string; - x5c?: string[]; -} - -/* ============================================================================ - Strong return types -============================================================================ */ - -export interface BuiltCredential { - /** e.g., "BirthCertificateCredential-sdjwt" or "DrivingLicenseCredential-mdoc" */ - credentialSupportedId: string; - /** Derived from template.format ("vc+sd-jwt" | "mdoc") */ - format: CredentialFormat; - /** User-provided payload (validated, with vct removed) */ - payload: Record; - /** Optional disclosure frame (usually for SD-JWT) */ - disclosureFrame?: DisclosureFrame; -} - -export interface BuiltCredentialOfferBase { - /** Resolved signer option (DID or x5c) */ - signerOption?: ResolvedSignerOption; - /** Normalized credential entries */ - credentials: BuiltCredential[]; - /** Optional public issuer id to include */ - publicIssuerId?: string; -} - -/** Final payload = base + EXACTLY ONE of the two flows */ -export type CredentialOfferPayload = BuiltCredentialOfferBase & - ( - | { - preAuthorizedCodeFlowConfig: { - txCode: { description?: string; length: number; input_mode: 'numeric' | 'text' | 'alphanumeric' }; - authorizationServerUrl: string; - }; - authorizationCodeFlowConfig?: never; - } - | { - authorizationCodeFlowConfig: { - authorizationServerUrl: string; - }; - preAuthorizedCodeFlowConfig?: never; - } - ); - -/* ============================================================================ - Small Utilities -============================================================================ */ - -const isNil = (v: unknown): v is null | undefined => null == v; -const isEmptyString = (v: unknown): boolean => 'string' === typeof v && '' === v.trim(); -const isRecord = (v: unknown): v is Record => Boolean(v) && 'object' === typeof v && !Array.isArray(v); - -/** Map DB format string -> API enum */ -function mapDbFormatToApiFormat(db: string): CredentialFormat { - const norm = db.toLowerCase().replace(/_/g, '-'); - if ('sd-jwt' === norm || 'vc+sd-jwt' === norm || 'sdjwt' === norm || 'sd+jwt-vc' === norm) { - return CredentialFormat.SdJwtVc; - } - if ('mdoc' === norm || 'mso-mdoc' === norm || 'mso-mdoc' === norm) { - return CredentialFormat.Mdoc; - } - throw new Error(`Unsupported template format: ${db}`); -} - -/** Map API enum -> id suffix required for credentialSupportedId */ -function formatSuffix(api: CredentialFormat): 'sdjwt' | 'mdoc' { - return api === CredentialFormat.SdJwtVc ? 'sdjwt' : 'mdoc'; -} - -/* ============================================================================ - Validation of Payload vs Template Attributes -============================================================================ */ - -/** Throw if any template-mandatory claim is missing/empty in payload. */ -function assertMandatoryClaims( - payload: Record, - attributes: TemplateAttributes, - ctx: { templateId: string } -): void { - const missing: string[] = []; - for (const [claim, def] of Object.entries(attributes)) { - if (!def?.mandatory) { - continue; - } - const val = payload[claim]; - if (isNil(val) || isEmptyString(val)) { - missing.push(claim); - } - } - if (missing.length) { - throw new Error(`Missing mandatory claims for template "${ctx.templateId}": ${missing.join(', ')}`); - } -} - -/* ============================================================================ - JsonValue → TemplateAttributes Narrowing (Type Guards) -============================================================================ */ - -function isDisplayArray(v: unknown): v is { name: string; locale: string }[] { - return Array.isArray(v) && v.every((d) => isRecord(d) && 'string' === typeof d.name && 'string' === typeof d.locale); -} - -function isTemplateAttribute(v: unknown): v is TemplateAttribute { - return ( - isRecord(v) && isDisplayArray(v.display) && 'boolean' === typeof v.mandatory && 'string' === typeof v.value_type - ); -} - -/** Accept `unknown` so predicate type (TemplateAttributes) is assignable to parameter type. */ -function isTemplateAttributes(v: unknown): v is TemplateAttributes { - if (!isRecord(v)) { - return false; - } - return Object.values(v).every(isTemplateAttribute); -} - -/** Runtime assert + narrow Prisma.JsonValue → TemplateAttributes */ -function ensureTemplateAttributes(v: Prisma.JsonValue): TemplateAttributes { - if (!isTemplateAttributes(v)) { - throw new Error('Invalid template.attributes shape. Expecting TemplateAttributes map.'); - } - return v; -} - -/* ============================================================================ - Builders -============================================================================ */ - -/** Build one credential block normalized to API format (using the template's format). */ -function buildOneCredential( - cred: CredentialRequestDtoLike, - template: credential_templates, - attrs: TemplateAttributes -): BuiltCredential { - // 1) Validate payload against template attributes - assertMandatoryClaims(cred.payload, attrs, { templateId: cred.templateId }); - - // 2) Decide API format from DB format - const apiFormat = mapDbFormatToApiFormat(template.format); - - // 3) Build supportedId from template.name + suffix ("-sdjwt" | "-mdoc") - const suffix = formatSuffix(apiFormat); - const credentialSupportedId = `${template.name}-${suffix}`; - - // 4) Strip vct ALWAYS (per requirement) - const payload = { ...(cred.payload as Record) }; - delete (payload as Record).vct; - - return { - credentialSupportedId, // e.g., "BirthCertificateCredential-sdjwt" - format: apiFormat, // 'vc+sd-jwt' | 'mdoc' - payload, // without vct - ...(cred.disclosureFrame ? { disclosureFrame: cred.disclosureFrame } : {}) - }; -} - -/** - * Build the full OID4VC credential offer payload. - * - Verifies template IDs - * - Validates mandatory claims per template - * - Normalizes formats & IDs - * - Enforces XOR of flow configs - * - Removes issuerId from the final envelope - * - Removes vct from all payloads - * - Sets credentialSupportedId = "-sdjwt|mdoc" - */ -export function buildCredentialOfferPayload( - dto: CreateOidcCredentialOfferDtoLike, - templates: credential_templates[], - signerOption?: ResolvedSignerOption // <-- now optional -): CredentialOfferPayload { - // Index templates - const byId = new Map(templates.map((t) => [t.id, t])); - - // Verify all requested templateIds exist - const unknown = dto.credentials.map((c) => c.templateId).filter((id) => !byId.has(id)); - if (unknown.length) { - throw new Error(`Unknown template ids: ${unknown.join(', ')}`); - } - - // Build credentials - const credentials: BuiltCredential[] = dto.credentials.map((cred) => { - const template = byId.get(cred.templateId)!; - const attrs = ensureTemplateAttributes(template.attributes); // narrow JsonValue safely - return buildOneCredential(cred, template, attrs); - }); - - // --- Base envelope (issuerId deliberately NOT included) --- - const base: BuiltCredentialOfferBase = { - credentials, - ...(dto.publicIssuerId ? { publicIssuerId: dto.publicIssuerId } : {}), - ...(signerOption ? { signerOption } : {}) // <-- add only if provided - }; - - // XOR flow selection (defensive) - const hasPre = Boolean(dto.preAuthorizedCodeFlowConfig); - const hasAuth = Boolean(dto.authorizationCodeFlowConfig); - if (hasPre === hasAuth) { - throw new Error('Provide exactly one of preAuthorizedCodeFlowConfig or authorizationCodeFlowConfig.'); - } - - if (hasPre) { - return { - ...base, - preAuthorizedCodeFlowConfig: dto.preAuthorizedCodeFlowConfig! // definite since hasPre - }; - } - - return { - ...base, - authorizationCodeFlowConfig: dto.authorizationCodeFlowConfig! // definite since !hasPre - }; -} - -// ----------------------------------------------------------------------------- -// Builder: Update Credential Offer -// ----------------------------------------------------------------------------- -export function buildUpdateCredentialOfferPayload( - dto: CreateOidcCredentialOfferDtoLike, - templates: credential_templates[] -): { credentials: BuiltCredential[] } { - // Index templates by id - const byId = new Map(templates.map((t) => [t.id, t])); - - // Validate all templateIds exist - const unknown = dto.credentials.map((c) => c.templateId).filter((id) => !byId.has(id)); - if (unknown.length) { - throw new Error(`Unknown template ids: ${unknown.join(', ')}`); - } - - // Validate each credential against its template - const credentials: BuiltCredential[] = dto.credentials.map((cred) => { - const template = byId.get(cred.templateId)!; - const attrs = ensureTemplateAttributes(template.attributes); // safely narrow JsonValue - - // check that all payload keys exist in template attributes - const payloadKeys = Object.keys(cred.payload); - const invalidKeys = payloadKeys.filter((k) => !attrs[k]); - if (invalidKeys.length) { - throw new Error(`Invalid attributes for template "${cred.templateId}": ${invalidKeys.join(', ')}`); - } - - // also validate mandatory fields are present - assertMandatoryClaims(cred.payload, attrs, { templateId: cred.templateId }); - - // build minimal normalized credential (no vct, issuerId, etc.) - const apiFormat = mapDbFormatToApiFormat(template.format); - const suffix = formatSuffix(apiFormat); - const credentialSupportedId = `${template.name}-${suffix}`; - return { - credentialSupportedId, - format: apiFormat, - payload: cred.payload, - ...(cred.disclosureFrame ? { disclosureFrame: cred.disclosureFrame } : {}) - }; - }); - - // Only return credentials array here (update flow doesn't need preAuth/auth configs) - return { - credentials - }; -} - -export function buildCredentialOfferUrl(baseUrl: string, getAllCredentialOffer: GetAllCredentialOffer): string { - const criteriaParams: string[] = []; - - if (getAllCredentialOffer.publicIssuerId) { - criteriaParams.push(`publicIssuerId=${encodeURIComponent(getAllCredentialOffer.publicIssuerId)}`); - } - - if (getAllCredentialOffer.preAuthorizedCode) { - criteriaParams.push(`preAuthorizedCode=${encodeURIComponent(getAllCredentialOffer.preAuthorizedCode)}`); - } - - if (getAllCredentialOffer.state) { - criteriaParams.push(`state=${encodeURIComponent(getAllCredentialOffer.state)}`); - } - - if (getAllCredentialOffer.credentialOfferUri) { - criteriaParams.push(`credentialOfferUri=${encodeURIComponent(getAllCredentialOffer.credentialOfferUri)}`); - } - - if (getAllCredentialOffer.authorizationCode) { - criteriaParams.push(`authorizationCode=${encodeURIComponent(getAllCredentialOffer.authorizationCode)}`); - } - - // Append query string if any params exist - return 0 < criteriaParams.length ? `${baseUrl}?${criteriaParams.join('&')}` : baseUrl; -} diff --git a/apps/issuance/libs/helpers/issuer.metadata.ts b/apps/issuance/libs/helpers/issuer.metadata.ts deleted file mode 100644 index 76d183052..000000000 --- a/apps/issuance/libs/helpers/issuer.metadata.ts +++ /dev/null @@ -1,244 +0,0 @@ -/* eslint-disable camelcase */ -import { oidc_issuer, Prisma } from '@prisma/client'; -import { batchCredentialIssuanceDefault } from '../../constant/issuance'; -import { CreateOidcCredentialOffer } from 'apps/issuance/interfaces/oid4vc-issuer-sessions.interfaces'; - -type AttributeDisplay = { name: string; locale: string }; -type AttributeDef = { - display?: AttributeDisplay[]; - mandatory?: boolean; - value_type: 'string' | 'date' | 'number' | 'boolean' | string; -}; -type AttributesMap = Record; - -type CredentialDisplayItem = { - logo?: { uri: string; alt_text?: string }; - name: string; - locale?: string; - description?: string; -}; -type Appearance = { - display: CredentialDisplayItem[]; -}; - -type CredentialConfig = { - format: string; - vct?: string; - scope: string; - doctype?: string; - claims: Record< - string, - { - mandatory?: boolean; - value_type: string; - display?: AttributeDisplay[]; - } - >; - credential_signing_alg_values_supported: string[]; - cryptographic_binding_methods_supported: string[]; - display: { name: string; description?: string; locale?: string }[]; -}; - -type CredentialConfigurationsSupported = { - credentialConfigurationsSupported: Record; -}; - -// ---- Static Lists (as requested) ---- -const STATIC_CREDENTIAL_ALGS = ['ES256', 'EdDSA'] as const; -const STATIC_BINDING_METHODS = ['did:key'] as const; -const DOCTYPE = 'org.iso.18013.5.1'; // for mso_mdoc format -const MSO_MDOC = 'mso_mdoc'; // alternative format value - -// Safe coercion helpers -function coerceJsonObject(v: Prisma.JsonValue): T | null { - if (null == v) { - return null; - } - if ('string' === typeof v) { - try { - return JSON.parse(v) as T; - } catch { - return null; - } - } - return v as unknown as T; // already a JsonObject/JsonArray -} -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function isAttributesMap(x: any): x is AttributesMap { - return x && 'object' === typeof x && !Array.isArray(x); -} -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function isAppearance(x: any): x is Appearance { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return x && 'object' === typeof x && Array.isArray((x as any).display); -} - -// Prisma row shape -type TemplateRowPrisma = { - id: string; - name: string; - description?: string | null; - format?: string | null; - canBeRevoked?: boolean | null; - attributes: Prisma.JsonValue; // JsonValue from DB - appearance: Prisma.JsonValue; // JsonValue from DB - issuerId: string; - createdAt?: Date | string; - updatedAt?: Date | string; -}; - -/** - * Build agent payload from Prisma rows (attributes/appearance are Prisma.JsonValue). - * Safely coerces JSON and then builds the same structure as Builder #2. - */ -export function buildCredentialConfigurationsSupported( - templates: TemplateRowPrisma[], - opts?: { - vct?: string; - scopeVct?: string; - keyResolver?: (t: TemplateRowPrisma) => string; - format?: string; - } -): CredentialConfigurationsSupported { - const format = opts?.format ?? 'vc+sd-jwt'; - const credentialConfigurationsSupported: Record = {}; - for (const t of templates) { - // Coerce JSON fields - const attrs = coerceJsonObject(t.attributes); - const app = coerceJsonObject(t.appearance); - - if (!isAttributesMap(attrs)) { - throw new Error(`Template ${t.id}: invalid attributes JSON`); - } - if (!isAppearance(app)) { - throw new Error(`Template ${t.id}: invalid appearance JSON (missing display)`); - } - - // ---- dynamic format per row ---- - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const rowFormat = (t as any).format ?? format; - const suffix = rowFormat === `${MSO_MDOC}` ? 'mdoc' : 'sdjwt'; - - // key: keep your keyResolver override; otherwise include suffix - const key = 'function' === typeof opts?.keyResolver ? opts.keyResolver(t) : `${t.name}-${suffix}`; - - // vct/scope: vct only for non-mdoc; scope always uses suffix - const vct = opts?.vct ?? t.name; - const scopeBase = opts?.scopeVct ?? vct; - const scope = `openid4vc:credential:${scopeBase}-${suffix}`; - const claims = Object.fromEntries( - Object.entries(attrs).map(([claimName, def]) => { - const d = def as AttributeDef; - return [ - claimName, - { - value_type: d.value_type, - mandatory: d.mandatory ?? false, // always include, default to false - display: Array.isArray(d.display) ? d.display.map((x) => ({ name: x.name, locale: x.locale })) : undefined - } - ]; - }) - ); - - const display = - app.display.map((d) => ({ - name: d.name, - description: d.description, - locale: d.locale - })) ?? []; - - // assemble per-template config - credentialConfigurationsSupported[key] = { - format: rowFormat, - scope, - claims, - credential_signing_alg_values_supported: [...STATIC_CREDENTIAL_ALGS], - cryptographic_binding_methods_supported: [...STATIC_BINDING_METHODS], - display, - ...(rowFormat === `${MSO_MDOC}` - ? { doctype: `${DOCTYPE}` } // static for mdoc - : { vct }) // keep vct only for non-mdoc - }; - } - - return { - credentialConfigurationsSupported - }; -} - -// Default DPoP list for issuer-level metadata (match your example) -const ISSUER_DPOP_ALGS_DEFAULT = ['RS256', 'ES256'] as const; - -// ---------- Safe coercion ---------- -function coerceJson(v: Prisma.JsonValue): T | null { - if (null == v) { - return null; - } - if ('string' === typeof v) { - try { - return JSON.parse(v) as T; - } catch { - return null; - } - } - return v as unknown as T; -} - -type DisplayItem = { - name: string; - locale?: string; - description?: string; - logo?: { uri: string; alt_text?: string }; -}; - -function isDisplayArray(x: unknown): x is DisplayItem[] { - return ( - Array.isArray(x) && - x.every( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (i) => i && 'object' === typeof i && 'string' === typeof (i as any).name - ) - ); -} - -// ---------- Builder you asked for ---------- -/** - * Build issuer metadata payload from issuer row + credential configurations. - * - * @param credentialConfigurations Object with credentialConfigurationsSupported (from your template builder) - * @param oidcIssuer OID4VC issuer row (uses publicIssuerId and metadata -> display) - * @param opts Optional overrides: dpopAlgs[], accessTokenSignerKeyType - */ -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -export function buildIssuerPayload( - credentialConfigurations: CredentialConfigurationsSupported, - oidcIssuer: oidc_issuer, - opts?: { - dpopAlgs?: string[]; - accessTokenSignerKeyType?: string; - } -) { - if (!oidcIssuer?.publicIssuerId || 'string' !== typeof oidcIssuer.publicIssuerId) { - throw new Error('Invalid issuer: missing publicIssuerId'); - } - - const rawDisplay = coerceJson(oidcIssuer.metadata); - const display: DisplayItem[] = isDisplayArray(rawDisplay) ? rawDisplay : []; - - return { - display, - dpopSigningAlgValuesSupported: opts?.dpopAlgs ?? [...ISSUER_DPOP_ALGS_DEFAULT], - credentialConfigurationsSupported: credentialConfigurations.credentialConfigurationsSupported ?? {}, - batchCredentialIssuance: { - batchSize: oidcIssuer?.batchCredentialIssuanceSize ?? batchCredentialIssuanceDefault - } - }; -} - -export function extractTemplateIds(offer: CreateOidcCredentialOffer): string[] { - if (!offer?.credentials || !Array.isArray(offer.credentials)) { - return []; - } - - return offer.credentials.map((c) => c.templateId).filter((id): id is string => Boolean(id)); -} From 5d3ed7ef4f305267a252e242845eb9ac0a390d23 Mon Sep 17 00:00:00 2001 From: Tipu_Singh Date: Tue, 30 Sep 2025 10:47:00 +0530 Subject: [PATCH 6/8] feat: create credential offer API Signed-off-by: Tipu_Singh --- .../dtos/issuer-sessions.dto.ts | 153 ++++++++++++++++++ .../dtos/oid4vc-issuer-template.dto.ts | 29 +++- .../oid4vc-issuance.controller.ts | 29 ++++ .../oid4vc-issuance.service.ts | 5 + .../interfaces/oid4vc-template.interfaces.ts | 2 + .../libs/helpers/issuer.metadata.ts | 19 ++- .../src/oid4vc-issuance.controller.ts | 10 ++ .../src/oid4vc-issuance.service.ts | 103 +++++++----- 8 files changed, 305 insertions(+), 45 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 e28d6d282..4d056d2fc 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 @@ -225,3 +225,156 @@ export class UpdateCredentialRequestDto { credentialOfferId?: string; } + +export class SignerOptionsDto { + @IsString() + @IsIn(['did', 'x5c'], { message: 'method must be either "did" or "x5c"' }) + method: string; + + @IsString() + @IsOptional() + did?: string; + + @IsArray() + @IsOptional() + x5c?: string[]; +} + +export class CredentialDto { + @ApiProperty({ + description: 'Unique ID of the supported credential', + example: 'DrivingLicenseCredential-mdoc' + }) + @IsString() + credentialSupportedId: string; + + @ApiProperty({ + description: 'Signer options for credential issuance', + example: { + method: 'x5c', + x5c: [ + 'MIIB3jCCAZCgAwIBAgIQQfBdIK9v3TIzKR+1HjlixDAFBgMrZXAwJDEUMBIGA1UEAxMLRFkgdGVzdCBvcmcxDDAKBgNVBAYTA0lORDAeFw0yNTA5MjQwMDAwMDBaFw0yODA5MjQwMDAwMDBaMCQxFDASBgNVBAMTC0RZIHRlc3Qgb3JnMQwwCgYDVQQGEwNJTkQwKjAFBgMrZXADIQDIkLycOlkHP6+MG4rprj8fyxRfwqhH8Xx9v0XxCd175aOB1zCB1DAdBgNVHQ4EFgQUbqjjbQgbAx3lPjkPBVQwvvF14agwDgYDVR0PAQH/BAQDAgGGMBUGA1UdJQEB/wQLMAkGByiBjF0FAQIwOwYDVR0SBDQwMoIXaHR0cDovL3Rlc3QuZXhhbXBsZS5jb22GF2h0dHA6Ly90ZXN0LmV4YW1wbGUuY29tMDsGA1UdEQQ0MDKCF2h0dHA6Ly90ZXN0LmV4YW1wbGUuY29thhdodHRwOi8vdGVzdC5leGFtcGxlLmNvbTASBgNVHRMBAf8ECDAGAQH/AgEAMAUGAytlcANBALTqC64XSTRUoMmwYbCD/z46U/Je6IeQsh6qq4qXh+wfnMIfJMvLQnG+nMkfeAs3zYAwjK6sCZ/7lHkEJnYObQ4=' + ] + } + }) + @ValidateNested() + @Type(() => SignerOptionsDto) + signerOptions: SignerOptionsDto; + + @ApiProperty({ + description: 'Credential format type', + enum: ['mso_mdoc', 'vc+sd-jwt'], + example: 'mso_mdoc' + }) + @IsString() + @IsIn(['mso_mdoc', 'vc+sd-jwt'], { message: 'format must be either "mso_mdoc" or "vc+sd-jwt"' }) + format: string; + + @ApiProperty({ + description: 'Credential payload (namespace data, validity info, etc.)', + example: { + namespaces: { + 'org.iso.23220.photoID.1': { + birth_date: '1970-02-14', + family_name: 'Müller-Lüdenscheid', + given_name: 'Ford Praxibetel', + document_number: 'LA001801M' + } + }, + validityInfo: { + validFrom: '2025-04-23T14:34:09.188Z', + validUntil: '2026-05-03T14:34:09.188Z' + } + } + }) + @ValidateNested() + payload: object; +} + +export class CreateCredentialOfferD2ADto { + @ApiProperty({ + description: 'Public identifier of the issuer visible to verifiers and wallets.', + example: 'dy-gov' + }) + @IsString() + publicIssuerId: string; + + @ApiProperty({ + description: 'List of credentials to be issued under this offer.', + type: [CredentialDto], + example: [ + { + credentialSupportedId: 'DrivingLicenseCredential-mdoc', + signerOptions: { + method: 'x5c', + x5c: [ + 'MIIB3jCCAZCgAwIBAgIQQfBdIK9v3TIzKR+1HjlixDAFBgMrZXAwJDEUMBIGA1UEAxMLRFkgdGVzdCBvcmcxDDAKBgNVBAYTA0lORDAeFw0yNTA5MjQwMDAwMDBaFw0yODA5MjQwMDAwMDBaMCQxFDASBgNVBAMTC0RZIHRlc3Qgb3JnMQwwCgYDVQQGEwNJTkQwKjAFBgMrZXADIQDIkLycOlkHP6+MG4rprj8fyxRfwqhH8Xx9v0XxCd175aOB1zCB1DAdBgNVHQ4EFgQUbqjjbQgbAx3lPjkPBVQwvvF14agwDgYDVR0PAQH/BAQDAgGGMBUGA1UdJQEB/wQLMAkGByiBjF0FAQIwOwYDVR0SBDQwMoIXaHR0cDovL3Rlc3QuZXhhbXBsZS5jb22GF2h0dHA6Ly90ZXN0LmV4YW1wbGUuY29tMDsGA1UdEQQ0MDKCF2h0dHA6Ly90ZXN0LmV4YW1wbGUuY29thhdodHRwOi8vdGVzdC5leGFtcGxlLmNvbTASBgNVHRMBAf8ECDAGAQH/AgEAMAUGAytlcANBALTqC64XSTRUoMmwYbCD/z46U/Je6IeQsh6qq4qXh+wfnMIfJMvLQnG+nMkfeAs3zYAwjK6sCZ/7lHkEJnYObQ4=' + ] + }, + format: 'mso_mdoc', + payload: { + namespaces: { + 'org.iso.23220.photoID.1': { + birth_date: '1970-02-14', + family_name: 'Müller-Lüdenscheid', + given_name: 'Ford Praxibetel', + document_number: 'LA001801M' + } + }, + validityInfo: { + validFrom: '2025-04-23T14:34:09.188Z', + validUntil: '2026-05-03T14:34:09.188Z' + } + } + } + ] + }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CredentialDto) + credentials: CredentialDto[]; + + @ApiPropertyOptional({ + description: 'Pre-Authorized Code Flow configuration. Provide this OR authorizationCodeFlowConfig (XOR rule).', + type: PreAuthorizedCodeFlowConfigDto, + example: { + preAuthorizedCode: 'abcd1234xyz', + txCode: { + length: 8, + inputMode: 'numeric' + }, + userPinRequired: true + } + }) + @IsOptional() + @ValidateNested() + @Type(() => PreAuthorizedCodeFlowConfigDto) + preAuthorizedCodeFlowConfig?: PreAuthorizedCodeFlowConfigDto; + + @ApiPropertyOptional({ + description: 'Authorization Code Flow configuration. Provide this OR preAuthorizedCodeFlowConfig (XOR rule).', + type: AuthorizationCodeFlowConfigDto, + example: { + clientId: 'wallet-app', + redirectUri: 'https://wallet.example.org/callback', + scope: 'openid vc_authn', + state: 'xyz-987654321' + } + }) + @IsOptional() + @ValidateNested() + @Type(() => AuthorizationCodeFlowConfigDto) + authorizationCodeFlowConfig?: AuthorizationCodeFlowConfigDto; + + @ApiPropertyOptional({ + description: 'Internal identifier of the issuer (optional, for backend use).', + example: 'issuer-12345' + }) + @IsOptional() + issuerId?: string; + + @ExactlyOneOf(['preAuthorizedCodeFlowConfig', 'authorizationCodeFlowConfig'], { + message: 'Provide exactly one of preAuthorizedCodeFlowConfig or authorizationCodeFlowConfig.' + }) + private readonly _exactlyOne?: unknown; +} diff --git a/apps/api-gateway/src/oid4vc-issuance/dtos/oid4vc-issuer-template.dto.ts b/apps/api-gateway/src/oid4vc-issuance/dtos/oid4vc-issuer-template.dto.ts index 96332d729..1035166d7 100644 --- a/apps/api-gateway/src/oid4vc-issuance/dtos/oid4vc-issuer-template.dto.ts +++ b/apps/api-gateway/src/oid4vc-issuance/dtos/oid4vc-issuer-template.dto.ts @@ -7,7 +7,9 @@ import { ValidateNested, IsObject, IsNotEmpty, - IsArray + IsArray, + ValidateIf, + IsEmpty } from 'class-validator'; import { ApiExtraModels, ApiProperty, ApiPropertyOptional, getSchemaPath, PartialType } from '@nestjs/swagger'; import { Type } from 'class-transformer'; @@ -129,6 +131,31 @@ export class CreateCredentialTemplateDto { @IsEnum(['mso_mdoc', 'vc+sd-jwt']) format: 'mso_mdoc' | 'vc+sd-jwt'; + @ApiPropertyOptional({ + description: 'Document type (required when format is "mso_mdoc"; must NOT be provided when format is "vc+sd-jwt")', + example: 'org.iso.23220.photoID.1' + }) + @ValidateIf((o: CreateCredentialTemplateDto) => 'mso_mdoc' === o.format) + @IsString() + doctype?: string; + + @ValidateIf((o: CreateCredentialTemplateDto) => 'vc+sd-jwt' === o.format) + @IsEmpty({ message: 'doctype must not be provided when format is "vc+sd-jwt"' }) + readonly _doctypeAbsentGuard?: unknown; + + @ApiPropertyOptional({ + description: + 'Verifiable Credential Type (required when format is "vc+sd-jwt"; must NOT be provided when format is "mso_mdoc")', + example: 'org.iso.18013.5.1.mDL' + }) + @ValidateIf((o: CreateCredentialTemplateDto) => 'vc+sd-jwt' === o.format) + @IsString() + vct?: string; + + @ValidateIf((o: CreateCredentialTemplateDto) => 'mso_mdoc' === o.format) + @IsEmpty({ message: 'vct must not be provided when format is "mso_mdoc"' }) + readonly _vctAbsentGuard?: unknown; + @ApiProperty({ default: false, description: 'Indicates whether credentials can be revoked' }) @IsBoolean() canBeRevoked = false; 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 a0212478f..f88b8718a 100644 --- a/apps/api-gateway/src/oid4vc-issuance/oid4vc-issuance.controller.ts +++ b/apps/api-gateway/src/oid4vc-issuance/oid4vc-issuance.controller.ts @@ -48,6 +48,7 @@ import { CreateCredentialTemplateDto, UpdateCredentialTemplateDto } from './dtos import { OidcIssueCredentialDto } from './dtos/oid4vc-credential-wh.dto'; import { Oid4vcIssuanceService } from './oid4vc-issuance.service'; import { + CreateCredentialOfferD2ADto, CreateOidcCredentialOfferDto, GetAllCredentialOfferDto, UpdateCredentialRequestDto @@ -584,6 +585,34 @@ export class Oid4vcIssuanceController { return res.status(HttpStatus.NO_CONTENT).json(finalResponse); } + @Post('/orgs/:orgId/oid4vc/create-offer/agent') + @ApiOperation({ + summary: 'Create OID4VC Credential Offer direct to agent', + description: 'Creates a new OIDC4VCI credential-offer for a given issuer.' + }) + @ApiResponse({ status: HttpStatus.CREATED, description: 'Credential offer created successfully.' }) + @ApiBearerAuth() + @Roles(OrgRoles.OWNER) + @UseGuards(AuthGuard('jwt'), OrgRolesGuard) + async createOidcCredentialOfferD2A( + @Param('orgId') + orgId: string, + @Body() oidcCredentialD2APayload: CreateCredentialOfferD2ADto, + @Res() res: Response + ): Promise { + const credentialOffer = await this.oid4vcIssuanceService.createOidcCredentialOfferD2A( + oidcCredentialD2APayload, + orgId + ); + const finalResponse: IResponse = { + statusCode: HttpStatus.CREATED, + message: ResponseMessages.oidcIssuerSession.success.create, + data: credentialOffer + }; + + return res.status(HttpStatus.CREATED).json(finalResponse); + } + /** * Catch issue credential webhook responses * @param oidcIssueCredentialDto The details of the oid4vc issued credential 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 de051536c..b5282d9dc 100644 --- a/apps/api-gateway/src/oid4vc-issuance/oid4vc-issuance.service.ts +++ b/apps/api-gateway/src/oid4vc-issuance/oid4vc-issuance.service.ts @@ -98,6 +98,11 @@ export class Oid4vcIssuanceService extends BaseService { return this.natsClient.sendNatsMessage(this.issuanceProxy, 'oid4vc-create-credential-offer', payload); } + async createOidcCredentialOfferD2A(oidcCredentialD2APayload, orgId: string): Promise { + const payload = { oidcCredentialD2APayload, orgId }; + return this.natsClient.sendNatsMessage(this.issuanceProxy, 'oid4vc-create-credential-offer-D2A', payload); + } + async updateOidcCredentialOffer( oidcUpdateCredentialPayload: UpdateCredentialRequestDto, orgId: string, diff --git a/apps/oid4vc-issuance/interfaces/oid4vc-template.interfaces.ts b/apps/oid4vc-issuance/interfaces/oid4vc-template.interfaces.ts index 36fd87e04..5d2984414 100644 --- a/apps/oid4vc-issuance/interfaces/oid4vc-template.interfaces.ts +++ b/apps/oid4vc-issuance/interfaces/oid4vc-template.interfaces.ts @@ -21,6 +21,8 @@ export interface CreateCredentialTemplate { attributes: Prisma.JsonValue; appearance?: Prisma.JsonValue; issuerId: string; + vct?: string; + doctype?: string; } export interface UpdateCredentialTemplate extends Partial {} diff --git a/apps/oid4vc-issuance/libs/helpers/issuer.metadata.ts b/apps/oid4vc-issuance/libs/helpers/issuer.metadata.ts index 78c4da88e..22735c10a 100644 --- a/apps/oid4vc-issuance/libs/helpers/issuer.metadata.ts +++ b/apps/oid4vc-issuance/libs/helpers/issuer.metadata.ts @@ -47,7 +47,6 @@ type CredentialConfigurationsSupported = { // ---- Static Lists (as requested) ---- const STATIC_CREDENTIAL_ALGS = ['ES256', 'EdDSA'] as const; const STATIC_BINDING_METHODS = ['did:key'] as const; -const DOCTYPE = 'org.iso.18013.5.1'; // for mso_mdoc format const MSO_MDOC = 'mso_mdoc'; // alternative format value // Safe coercion helpers @@ -96,6 +95,7 @@ export function buildCredentialConfigurationsSupported( templates: TemplateRowPrisma[], opts?: { vct?: string; + doctype?: string; scopeVct?: string; keyResolver?: (t: TemplateRowPrisma) => string; format?: string; @@ -118,14 +118,23 @@ export function buildCredentialConfigurationsSupported( // ---- dynamic format per row ---- // eslint-disable-next-line @typescript-eslint/no-explicit-any const rowFormat = (t as any).format ?? format; + const isMdoc = rowFormat === `${MSO_MDOC}`; const suffix = rowFormat === `${MSO_MDOC}` ? 'mdoc' : 'sdjwt'; // key: keep your keyResolver override; otherwise include suffix const key = 'function' === typeof opts?.keyResolver ? opts.keyResolver(t) : `${t.name}-${suffix}`; // vct/scope: vct only for non-mdoc; scope always uses suffix - const vct = opts?.vct ?? t.name; - const scopeBase = opts?.scopeVct ?? vct; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const rowDoctype: string | undefined = opts?.doctype ?? (t as any).doctype; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const rowVct: string = opts?.vct ?? (t as any).vct ?? t.name; + + if (isMdoc && !rowDoctype) { + throw new Error(`Template ${t.id}: doctype is required for mdoc format`); + } + + const scopeBase = opts?.scopeVct ?? rowVct; const scope = `openid4vc:credential:${scopeBase}-${suffix}`; const claims = Object.fromEntries( Object.entries(attrs).map(([claimName, def]) => { @@ -156,9 +165,7 @@ export function buildCredentialConfigurationsSupported( credential_signing_alg_values_supported: [...STATIC_CREDENTIAL_ALGS], cryptographic_binding_methods_supported: [...STATIC_BINDING_METHODS], display, - ...(rowFormat === `${MSO_MDOC}` - ? { doctype: `${DOCTYPE}` } // static for mdoc - : { vct }) // keep vct only for non-mdoc + ...(isMdoc ? { doctype: rowDoctype as string } : { vct: rowVct }) }; } diff --git a/apps/oid4vc-issuance/src/oid4vc-issuance.controller.ts b/apps/oid4vc-issuance/src/oid4vc-issuance.controller.ts index 08cf5e45e..7d9f476f7 100644 --- a/apps/oid4vc-issuance/src/oid4vc-issuance.controller.ts +++ b/apps/oid4vc-issuance/src/oid4vc-issuance.controller.ts @@ -121,6 +121,16 @@ export class Oid4vcIssuanceController { return this.oid4vcIssuanceService.createOidcCredentialOffer(oidcCredentialPayload, orgId, userDetails, issuerId); } + @MessagePattern({ cmd: 'oid4vc-create-credential-offer-D2A' }) + async createOidcCredentialOfferD2A(payload: { + oidcCredentialD2APayload; + orgId: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }): Promise { + const { oidcCredentialD2APayload, orgId } = payload; + return this.oid4vcIssuanceService.createOidcCredentialOfferD2A(oidcCredentialD2APayload, orgId); + } + @MessagePattern({ cmd: 'oid4vc-update-credential-offer' }) // eslint-disable-next-line @typescript-eslint/no-explicit-any async updateOidcCredentialOffer(payload: { diff --git a/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts b/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts index 4b9e1c91c..afdf46be6 100644 --- a/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts +++ b/apps/oid4vc-issuance/src/oid4vc-issuance.service.ts @@ -48,19 +48,15 @@ import { } from '../libs/helpers/issuer.metadata'; import { CreateOidcCredentialOffer, - CredentialPayload, GetAllCredentialOffer, SignerMethodOption, - SignerOption, UpdateCredentialRequest } from '../interfaces/oid4vc-issuer-sessions.interfaces'; -import { BadRequestErrorDto } from 'apps/api-gateway/src/dtos/bad-request-error.dto'; import { buildCredentialOfferPayload, buildCredentialOfferUrl, CredentialOfferPayload } from '../libs/helpers/credential-sessions.builder'; -import { context } from '@opentelemetry/api'; type CredentialDisplayItem = { logo?: { uri: string; alt_text?: string }; @@ -293,7 +289,8 @@ export class Oid4vcIssuanceService { issuerId: string ): Promise { try { - const { name, description, format, canBeRevoked, attributes, appearance, signerOption } = CredentialTemplate; + const { name, description, format, canBeRevoked, attributes, appearance, signerOption, vct, doctype } = + CredentialTemplate; const checkNameExist = await this.oid4vcIssuanceRepository.getTemplateByNameForIssuer(name, issuerId); if (0 < checkNameExist.length) { throw new ConflictException(ResponseMessages.oidcTemplate.error.templateNameAlreadyExist); @@ -313,7 +310,14 @@ export class Oid4vcIssuanceService { if (!createdTemplate) { throw new InternalServerErrorException(ResponseMessages.oidcTemplate.error.createFailed); } - const issuerTemplateConfig = await this.buildOidcIssuerConfig(issuerId); + let opts = {}; + if (vct) { + opts = { ...opts, vct }; + } + if (doctype) { + opts = { ...opts, doctype }; + } + const issuerTemplateConfig = await this.buildOidcIssuerConfig(issuerId, opts); const agentDetails = await this.oid4vcIssuanceRepository.getAgentEndPoint(orgId); if (!agentDetails) { throw new NotFoundException(ResponseMessages.issuance.error.agentEndPointNotFound); @@ -472,7 +476,7 @@ export class Oid4vcIssuanceService { //TDOD: signerOption should be under credentials change this with x509 support //TDOD: signerOption should be under credentials change this with x509 support - const signerOptions: SignerOption[] = []; + const signerOptions = []; getAllOfferTemplates.forEach((template) => { if (template.signerOption === SignerMethodOption.DID) { signerOptions.push({ @@ -515,6 +519,58 @@ export class Oid4vcIssuanceService { } } + async createOidcCredentialOfferD2A(oidcCredentialD2APayload, orgId: string): Promise { + try { + for (const credential of oidcCredentialD2APayload.credentials) { + const { signerOptions } = credential; + + if (!signerOptions?.method) { + throw new BadRequestException(`signerOptions.method is required`); + } + if (signerOptions.method === SignerMethodOption.X5C) { + if (!signerOptions.x5c || 0 === signerOptions.x5c.length) { + // const x5cFromDb = await this.oid4vcIssuanceRepository.getIssuerX5c( + const x5cFromDb = 'Test'; + // If you want to use the actual DB call, uncomment and use: + // const x5cFromDb = await this.oid4vcIssuanceRepository.getIssuerX5c( + // oidcCredentialD2APayload.publicIssuerId, + // orgId + // ); + if (!x5cFromDb || 0 === x5cFromDb.length) { + throw new BadRequestException(`No x5c found for issuer`); + } + signerOptions.x5c = x5cFromDb; + } + } + + if (signerOptions.method === SignerMethodOption.DID) { + if (!signerOptions.did) { + const agentDetails = await this.oid4vcIssuanceRepository.getAgentEndPoint(orgId); + if (!agentDetails) { + throw new BadRequestException(`No DID found for issuer`); + } + signerOptions.did = agentDetails.orgDid; + } + } + } + + const url = await getAgentUrl( + await this.getAgentEndpoint(orgId), + CommonConstants.OIDC_ISSUER_SESSIONS_CREDENTIAL_OFFER + ); + const createCredentialOfferOnAgent = await this._oidcCreateCredentialOffer(oidcCredentialD2APayload, url, orgId); + if (!createCredentialOfferOnAgent) { + throw new NotFoundException(ResponseMessages.oidcIssuerSession.error.errorCreateOffer); + } + console.log('This is the createCredentialOfferOnAgent:', JSON.stringify(createCredentialOfferOnAgent, null, 2)); + + return createCredentialOfferOnAgent.response; + } catch (error) { + this.logger.error(`[createOidcCredentialOffer] - error: ${JSON.stringify(error)}`); + throw new RpcException(error.response ?? error); + } + } + async updateOidcCredentialOffer( updateOidcCredentialOffer: UpdateCredentialRequest, orgId: string, @@ -524,35 +580,6 @@ export class Oid4vcIssuanceService { if (!updateOidcCredentialOffer.issuerMetadata) { throw new BadRequestException('Please provide a valid issuerMetadata'); } - // TODO: Need to implement this in future if required - - // const filterTemplateIds = extractTemplateIds(updateOidcCredentialOffer); - // console.log('This is the filterTemplateIds:', filterTemplateIds); - // if (!filterTemplateIds) { - // throw new BadRequestException('Please provide a valid id'); - // } - // const getAllOfferTemplates = await this.issuanceRepository.getTemplateByIds(filterTemplateIds, issuerId); - // console.log('This is the getAllOfferTemplates:', getAllOfferTemplates); - // if (!getAllOfferTemplates || getAllOfferTemplates.length === 0) { - // throw new NotFoundException('No templates found for the issuer'); - // } - // const buildOidcUpdateCredentialOffer = buildUpdateCredentialOfferPayload( - // updateOidcCredentialOffer, - // getAllOfferTemplates - // ); - // if (!buildOidcUpdateCredentialOffer) { - // throw new BadRequestException('Error while creating oid4vc credential offer'); - // } - // const agentDetails = await this.issuanceRepository.getAgentEndPoint(orgId); - // if (!agentDetails) { - // throw new NotFoundException(ResponseMessages.oidcTemplate.error.issuerDetailsNotFound); - // } - // const issuanceMetadata= { - // issuanceMetadata: { - // issuerDid: agentDetails.orgDid, - // credentials: buildOidcUpdateCredentialOffer.credentials - // } - // } const url = await getAgentUrl( await this.getAgentEndpoint(orgId), CommonConstants.OIDC_ISSUER_SESSIONS_UPDATE_OFFER, @@ -632,12 +659,12 @@ export class Oid4vcIssuanceService { } // eslint-disable-next-line @typescript-eslint/explicit-function-return-type - async buildOidcIssuerConfig(issuerId: string) { + async buildOidcIssuerConfig(issuerId: string, configMetadata?) { try { const issuerDetails = await this.oid4vcIssuanceRepository.getOidcIssuerDetailsById(issuerId); const templates = await this.oid4vcIssuanceRepository.getTemplatesByIssuerId(issuerId); - const credentialConfigurationsSupported = buildCredentialConfigurationsSupported(templates); + const credentialConfigurationsSupported = buildCredentialConfigurationsSupported(templates, configMetadata); return buildIssuerPayload(credentialConfigurationsSupported, issuerDetails); } catch (error) { From 19bd6ebdf17fcdfe89509bbd64837d6b294ee1da Mon Sep 17 00:00:00 2001 From: Krishna Waske Date: Mon, 6 Oct 2025 13:22:40 +0530 Subject: [PATCH 7/8] fix: add todo Signed-off-by: Krishna Waske --- .../agent-service/dto/create-schema.dto.ts | 43 ++++++++++--------- 1 file changed, 23 insertions(+), 20 deletions(-) diff --git a/apps/api-gateway/src/agent-service/dto/create-schema.dto.ts b/apps/api-gateway/src/agent-service/dto/create-schema.dto.ts index f34d802ae..21dd0cf1b 100644 --- a/apps/api-gateway/src/agent-service/dto/create-schema.dto.ts +++ b/apps/api-gateway/src/agent-service/dto/create-schema.dto.ts @@ -2,26 +2,29 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsString, IsNotEmpty, IsArray } from 'class-validator'; export class CreateTenantSchemaDto { - @ApiProperty() - @IsString({ message: 'tenantId must be a string' }) @IsNotEmpty({ message: 'please provide valid tenantId' }) - tenantId: string; - - @ApiProperty() - @IsString({ message: 'schema version must be a string' }) @IsNotEmpty({ message: 'please provide valid schema version' }) - schemaVersion: string; + @ApiProperty() + @IsString({ message: 'tenantId must be a string' }) + @IsNotEmpty({ message: 'please provide valid tenantId' }) + tenantId: string; - @ApiProperty() - @IsString({ message: 'schema name must be a string' }) @IsNotEmpty({ message: 'please provide valid schema name' }) - schemaName: string; + @ApiProperty() + @IsString({ message: 'schema version must be a string' }) + @IsNotEmpty({ message: 'please provide valid schema version' }) + schemaVersion: string; - @ApiProperty() - @IsArray({ message: 'attributes must be an array' }) - @IsString({ each: true }) - @IsNotEmpty({ message: 'please provide valid attributes' }) - attributes: string[]; + @ApiProperty() + @IsString({ message: 'schema name must be a string' }) + @IsNotEmpty({ message: 'please provide valid schema name' }) + schemaName: string; - @ApiProperty() - - @IsNotEmpty({ message: 'please provide orgId' }) - orgId: string; -} \ No newline at end of file + @ApiProperty() + @IsArray({ message: 'attributes must be an array' }) + @IsString({ each: true }) + // TODO: IsNotEmpty won't work for array. Must use @ArrayNotEmpty() instead + @IsNotEmpty({ message: 'please provide valid attributes' }) + attributes: string[]; + + @ApiProperty() + @IsNotEmpty({ message: 'please provide orgId' }) + orgId: string; +} From 4e05b4bc2bde6e78051dc971b28083d82f7860e7 Mon Sep 17 00:00:00 2001 From: Krishna Waske Date: Mon, 6 Oct 2025 18:04:28 +0530 Subject: [PATCH 8/8] fix: claims format Signed-off-by: Krishna Waske --- .../dtos/oid4vc-issuer-template.dto.ts | 12 +++- .../oid4vc-issuance/dtos/oid4vc-issuer.dto.ts | 50 +++++++++-------- .../interfaces/oid4vc-issuance.interfaces.ts | 10 ++-- .../libs/helpers/issuer.metadata.ts | 56 ++++++++----------- 4 files changed, 66 insertions(+), 62 deletions(-) diff --git a/apps/api-gateway/src/oid4vc-issuance/dtos/oid4vc-issuer-template.dto.ts b/apps/api-gateway/src/oid4vc-issuance/dtos/oid4vc-issuer-template.dto.ts index 1035166d7..45e2a5fb7 100644 --- a/apps/api-gateway/src/oid4vc-issuance/dtos/oid4vc-issuer-template.dto.ts +++ b/apps/api-gateway/src/oid4vc-issuance/dtos/oid4vc-issuer-template.dto.ts @@ -21,10 +21,20 @@ export class CredentialAttributeDto { @IsBoolean() mandatory?: boolean; + // TODO: Check how do we handle claims with only path rpoperty like email, etc. @ApiProperty({ description: 'Type of the attribute value (string, number, date, etc.)' }) @IsString() value_type: string; + @ApiProperty({ + type: [String], + description: + 'Claims path pointer as per the draft 15 - https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0-ID2.html#name-claims-path-pointer' + }) + @IsArray() + @IsString({ each: true }) + path: string[]; + @ApiProperty({ type: [DisplayDto], required: false, description: 'Localized display values' }) @IsOptional() @ValidateNested({ each: true }) @@ -166,7 +176,7 @@ export class CreateCredentialTemplateDto { description: 'Attributes included in the credential template' }) @IsObject() - attributes: Record; + attributes: CredentialAttributeDto[]; @ApiProperty({ type: Object, diff --git a/apps/api-gateway/src/oid4vc-issuance/dtos/oid4vc-issuer.dto.ts b/apps/api-gateway/src/oid4vc-issuance/dtos/oid4vc-issuer.dto.ts index b9e67f423..fee4af086 100644 --- a/apps/api-gateway/src/oid4vc-issuance/dtos/oid4vc-issuer.dto.ts +++ b/apps/api-gateway/src/oid4vc-issuance/dtos/oid4vc-issuer.dto.ts @@ -1,26 +1,27 @@ /* eslint-disable camelcase */ -import { ApiExtraModels, ApiProperty, ApiPropertyOptional, getSchemaPath } from '@nestjs/swagger'; +import { ApiExtraModels, ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsString, IsOptional, IsBoolean, IsArray, ValidateNested, - IsObject, IsUrl, IsNotEmpty, IsDefined, IsInt } from 'class-validator'; -import { plainToInstance, Transform, Type } from 'class-transformer'; +import { Type } from 'class-transformer'; export class ClaimDto { @ApiProperty({ - description: 'The unique key for the claim (e.g. email, name)', - example: 'email' + description: 'The path for nested claims', + example: ['address', 'street_number'], + type: [String] }) - @IsString() - key: string; + @Type(() => String) + @IsArray() + path: string[]; @ApiProperty({ description: 'The display label for the claim', @@ -85,6 +86,7 @@ export class DisplayDto { logo?: LogoDto; } +// TODO: Check where it is used, coz no reference found @ApiExtraModels(ClaimDto) export class CredentialConfigurationDto { @ApiProperty({ @@ -110,25 +112,25 @@ export class CredentialConfigurationDto { @IsString() scope: string; - // @ApiProperty({ - // description: 'List of claims supported in this credential', - // type: [ClaimDto], - // }) - // @IsArray() - // @ValidateNested({ each: true }) - // @Type(() => ClaimDto) - // claims: ClaimDto[] @ApiProperty({ - description: 'Claims supported by this credential', - type: 'object', - additionalProperties: { $ref: getSchemaPath(ClaimDto) } + description: 'List of claims supported in this credential', + type: [ClaimDto] }) - @IsObject() + @IsArray() @ValidateNested({ each: true }) - @Transform(({ value }) => - Object.fromEntries(Object.entries(value || {}).map(([k, v]) => [k, plainToInstance(ClaimDto, v)])) - ) - claims: Record; + @Type(() => ClaimDto) + claims: ClaimDto[]; + // @ApiProperty({ + // description: 'Claims supported by this credential', + // type: 'object', + // additionalProperties: { $ref: getSchemaPath(ClaimDto) } + // }) + // @IsObject() + // @ValidateNested({ each: true }) + // @Transform(({ value }) => + // Object.fromEntries(Object.entries(value || {}).map(([k, v]) => [k, plainToInstance(ClaimDto, v)])) + // ) + // claims: Record; @ApiProperty({ type: [String] }) @IsArray() @@ -217,7 +219,7 @@ export enum AccessTokenSignerKeyType { ED25519 = 'ed25519' } -@ApiExtraModels(CredentialConfigurationDto) +// @ApiExtraModels(CredentialConfigurationDto) export class IssuerCreationDto { @ApiProperty({ description: 'Name of the issuer', diff --git a/apps/oid4vc-issuance/interfaces/oid4vc-issuance.interfaces.ts b/apps/oid4vc-issuance/interfaces/oid4vc-issuance.interfaces.ts index 1c55048af..04ff5ff3e 100644 --- a/apps/oid4vc-issuance/interfaces/oid4vc-issuance.interfaces.ts +++ b/apps/oid4vc-issuance/interfaces/oid4vc-issuance.interfaces.ts @@ -18,9 +18,9 @@ export interface OrgAgent { } export interface Claim { - key: string; - label: string; - required: boolean; + path: string[]; + label?: string; + required?: boolean; } export interface Logo { @@ -40,7 +40,7 @@ export interface CredentialConfiguration { vct?: string; doctype?: string; scope: string; - claims: Record; + claims: Claim[]; credential_signing_alg_values_supported: string[]; cryptographic_binding_methods_supported: string[]; display: Display[]; @@ -61,7 +61,7 @@ export interface IssuerCreation { accessTokenSignerKeyType?: AccessTokenSignerKeyType; display: Display[]; dpopSigningAlgValuesSupported?: string[]; - credentialConfigurationsSupported?: Record; + // credentialConfigurationsSupported?: Record; // Not used authorizationServerConfigs: AuthorizationServerConfig; batchCredentialIssuanceSize: number; } diff --git a/apps/oid4vc-issuance/libs/helpers/issuer.metadata.ts b/apps/oid4vc-issuance/libs/helpers/issuer.metadata.ts index 22735c10a..d00e2c78e 100644 --- a/apps/oid4vc-issuance/libs/helpers/issuer.metadata.ts +++ b/apps/oid4vc-issuance/libs/helpers/issuer.metadata.ts @@ -22,27 +22,25 @@ type Appearance = { display: CredentialDisplayItem[]; }; +type Claim = { + mandatory?: boolean; + // value_type: string; + path: string[]; + display?: AttributeDisplay[]; +}; + type CredentialConfig = { format: string; vct?: string; scope: string; doctype?: string; - claims: Record< - string, - { - mandatory?: boolean; - value_type: string; - display?: AttributeDisplay[]; - } - >; + claims: Claim[]; credential_signing_alg_values_supported: string[]; cryptographic_binding_methods_supported: string[]; display: { name: string; description?: string; locale?: string }[]; }; -type CredentialConfigurationsSupported = { - credentialConfigurationsSupported: Record; -}; +type CredentialConfigurationsSupported = CredentialConfig[]; // ---- Static Lists (as requested) ---- const STATIC_CREDENTIAL_ALGS = ['ES256', 'EdDSA'] as const; @@ -102,7 +100,7 @@ export function buildCredentialConfigurationsSupported( } ): CredentialConfigurationsSupported { const format = opts?.format ?? 'vc+sd-jwt'; - const credentialConfigurationsSupported: Record = {}; + const credentialConfigurationsSupported: CredentialConfigurationsSupported = []; for (const t of templates) { // Coerce JSON fields const attrs = coerceJsonObject(t.attributes); @@ -122,7 +120,7 @@ export function buildCredentialConfigurationsSupported( const suffix = rowFormat === `${MSO_MDOC}` ? 'mdoc' : 'sdjwt'; // key: keep your keyResolver override; otherwise include suffix - const key = 'function' === typeof opts?.keyResolver ? opts.keyResolver(t) : `${t.name}-${suffix}`; + // const key = 'function' === typeof opts?.keyResolver ? opts.keyResolver(t) : `${t.name}-${suffix}`; // vct/scope: vct only for non-mdoc; scope always uses suffix // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -136,19 +134,15 @@ export function buildCredentialConfigurationsSupported( const scopeBase = opts?.scopeVct ?? rowVct; const scope = `openid4vc:credential:${scopeBase}-${suffix}`; - const claims = Object.fromEntries( - Object.entries(attrs).map(([claimName, def]) => { - const d = def as AttributeDef; - return [ - claimName, - { - value_type: d.value_type, - mandatory: d.mandatory ?? false, // always include, default to false - display: Array.isArray(d.display) ? d.display.map((x) => ({ name: x.name, locale: x.locale })) : undefined - } - ]; - }) - ); + const claims = Object.entries(attrs).map(([claimName, def]) => { + const d = def as AttributeDef; + return { + path: [claimName], + // value_type: d.value_type, // Didn't find this in draft 15 + mandatory: d.mandatory ?? false, // always include, default to false + display: Array.isArray(d.display) ? d.display.map((x) => ({ name: x.name, locale: x.locale })) : undefined + }; + }); const display = app.display.map((d) => ({ @@ -158,7 +152,7 @@ export function buildCredentialConfigurationsSupported( })) ?? []; // assemble per-template config - credentialConfigurationsSupported[key] = { + credentialConfigurationsSupported.push({ format: rowFormat, scope, claims, @@ -166,12 +160,10 @@ export function buildCredentialConfigurationsSupported( cryptographic_binding_methods_supported: [...STATIC_BINDING_METHODS], display, ...(isMdoc ? { doctype: rowDoctype as string } : { vct: rowVct }) - }; + }); } - return { - credentialConfigurationsSupported - }; + return credentialConfigurationsSupported; } // Default DPoP list for issuer-level metadata (match your example) @@ -236,7 +228,7 @@ export function buildIssuerPayload( return { display, dpopSigningAlgValuesSupported: opts?.dpopAlgs ?? [...ISSUER_DPOP_ALGS_DEFAULT], - credentialConfigurationsSupported: credentialConfigurations.credentialConfigurationsSupported ?? {}, + credentialConfigurationsSupported: credentialConfigurations ?? [], batchCredentialIssuance: { batchSize: oidcIssuer?.batchCredentialIssuanceSize ?? batchCredentialIssuanceDefault }