src/verifier/oid4vp/oid4vp.service.ts
Properties |
|
Methods |
|
constructor(certService: CertService, keyChainService: KeyChainService, encryptionService: EncryptionService, configService: ConfigService, presentationsService: PresentationsService, sessionService: SessionService, sessionLogger: SessionLoggerService, webhookService: WebhookService, cryptoImplementationService: CryptoImplementationService)
|
||||||||||||||||||||||||||||||
|
Defined in src/verifier/oid4vp/oid4vp.service.ts:25
|
||||||||||||||||||||||||||||||
|
Parameters :
|
| Async createAuthorizationRequest | ||||||||||||||||
createAuthorizationRequest(sessionId: string, origin: string, noRedirect: unknown)
|
||||||||||||||||
|
Defined in src/verifier/oid4vp/oid4vp.service.ts:47
|
||||||||||||||||
|
Creates an authorization request for the OID4VP flow. This method generates a JWT that includes the necessary parameters for the authorization request. It initializes the session logging context and logs the start of the flow.
Parameters :
Returns :
Promise<string>
|
| Async createRequest | ||||||||||||||||||
createRequest(requestId: string, values: PresentationRequestOptions, tenantId: string, useDcApi: boolean, origin: string)
|
||||||||||||||||||
|
Defined in src/verifier/oid4vp/oid4vp.service.ts:239
|
||||||||||||||||||
|
Creates a request for the OID4VP flow.
Parameters :
Returns :
Promise<OfferResponse>
|
| Async getResponse | |||||||||
getResponse(body: AuthorizationResponse, sessionId: string)
|
|||||||||
|
Defined in src/verifier/oid4vp/oid4vp.service.ts:349
|
|||||||||
|
Processes the response from the wallet.
Parameters :
Returns :
unknown
|
| Public Readonly keyChainService |
Type : KeyChainService
|
|
Defined in src/verifier/oid4vp/oid4vp.service.ts:28
|
import { randomUUID } from "node:crypto";
import { BadRequestException, Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { plainToInstance } from "class-transformer";
import { validateOrReject } from "class-validator";
import { base64url } from "jose";
import { v4 } from "uuid";
import { EncryptionService } from "../../crypto/encryption/encryption.service";
import { CertService } from "../../crypto/key/cert/cert.service";
import { CryptoImplementationService } from "../../crypto/key/crypto-implementation/crypto-implementation.service";
import { KeyUsageType } from "../../crypto/key/entities/key-chain.entity";
import { KeyChainService } from "../../crypto/key/key-chain.service";
import { OfferResponse } from "../../issuer/issuance/oid4vci/dto/offer-request.dto";
import { SessionStatus } from "../../session/entities/session.entity";
import { SessionService } from "../../session/session.service";
import { SessionLoggerService } from "../../shared/utils/logger/session-logger.service";
import { SessionLogContext } from "../../shared/utils/logger/session-logger-context";
import { WebhookService } from "../../shared/utils/webhook/webhook.service";
import { AuthResponse } from "../presentations/dto/auth-response.dto";
import { PresentationsService } from "../presentations/presentations.service";
import { AuthorizationResponse } from "./dto/authorization-response.dto";
import { PresentationRequestOptions } from "./dto/presentation-request-options.dto";
@Injectable()
export class Oid4vpService {
constructor(
private readonly certService: CertService,
public readonly keyChainService: KeyChainService,
private readonly encryptionService: EncryptionService,
private readonly configService: ConfigService,
private readonly presentationsService: PresentationsService,
private readonly sessionService: SessionService,
private readonly sessionLogger: SessionLoggerService,
private readonly webhookService: WebhookService,
private readonly cryptoImplementationService: CryptoImplementationService,
) {}
/**
* Creates an authorization request for the OID4VP flow.
* This method generates a JWT that includes the necessary parameters for the authorization request.
* It initializes the session logging context and logs the start of the flow.
* @param session
* @param origin
* @param noRedirect
* @returns
*/
async createAuthorizationRequest(
sessionId: string,
origin: string,
noRedirect = false,
): Promise<string> {
const session = await this.sessionService.get(sessionId);
// if noRedirect is true, we want to keep the redirectUri undefined in the session, as it will be used by the client to decide whether to redirect or not after receiving the response. If it's defined, the client will always redirect, even if it was instructed not to.
if (noRedirect) {
await this.sessionService.add(session.id, {
redirectUri: null,
});
}
// Create session logging context
const logContext: SessionLogContext = {
sessionId: session.id,
tenantId: session.tenantId,
flowType: "OID4VP",
stage: "authorization_request",
};
this.sessionLogger.logFlowStart(logContext, {
requestId: session.requestId,
action: "create_authorization_request",
});
try {
const host = this.configService.getOrThrow<string>("PUBLIC_URL");
const tenantHost = `${host}/${session.tenantId}`;
const presentationConfig =
await this.presentationsService.getPresentationConfig(
session.requestId!,
session.tenantId,
);
let regCert: string | undefined = undefined;
const dcql_query = JSON.parse(
JSON.stringify(presentationConfig.dcql_query).replaceAll(
"<TENANT_URL>",
tenantHost,
),
);
//remove trusted_authorities from dcql
dcql_query.credentials = dcql_query.credentials.map((cred: any) => {
const { trusted_authorities, ...rest } = cred;
return rest;
});
/* if (
await this.registrarService.isEnabledForTenant(session.tenantId)
) {
const registrationCert = JSON.parse(
JSON.stringify(
presentationConfig.registrationCert,
).replaceAll("<TENANT_URL>", tenantHost),
);
regCert =
await this.registrarService.addRegistrationCertificate(
registrationCert,
dcql_query,
session.requestId!,
session.tenantId,
);
} */
const nonce = randomUUID();
await this.sessionService.add(session.id, {
vp_nonce: nonce,
});
this.sessionLogger.logAuthorizationRequest(logContext, {
requestId: session.requestId,
nonce,
regCert,
dcqlQueryCount: Array.isArray(dcql_query)
? dcql_query.length
: 1,
});
const lifeTime = 60 * 60;
const cert = await this.certService.find({
tenantId: session.tenantId,
type: KeyUsageType.Access,
certId: presentationConfig.accessKeyChainId ?? undefined,
});
const certHash = this.certService.getCertHash(cert);
// Use transaction_data from session (which may have been overridden) or fall back to config
const transaction_data =
(
session.transaction_data ??
presentationConfig.transaction_data
)?.map((td) => base64url.encode(JSON.stringify(td))) ||
undefined;
const request = {
payload: {
response_type: "vp_token",
client_id: "x509_hash:" + certHash,
response_uri: `${host}/${session.id}/oid4vp`,
response_mode: session.useDcApi
? "dc_api.jwt"
: "direct_post.jwt",
nonce,
expected_origins: session.useDcApi ? [origin] : undefined,
dcql_query,
client_metadata: {
jwks: {
keys: [
await this.encryptionService.getEncryptionPublicKey(
session.tenantId,
),
],
},
vp_formats_supported: {
mso_mdoc: {
alg: ["ES256"],
},
"dc+sd-jwt": {
"kb-jwt_alg_values":
this.cryptoImplementationService.getSupportedAlgorithms(),
"sd-jwt_alg_values":
this.cryptoImplementationService.getSupportedAlgorithms(),
},
},
encrypted_response_enc_values_supported: [
"A128GCM",
"A256GCM",
],
},
state: session.useDcApi ? undefined : session.id,
transaction_data,
//TODO: check if this value is correct accroding to https://openid.net/specs/openid-4-verifiable-presentations-1_0.html#name-aud-of-a-request-object
aud: "https://self-issued.me/v2",
exp: Math.floor(Date.now() / 1000) + lifeTime,
iat: Math.floor(Date.now() / 1000),
verifier_attestations: regCert
? [
{
format: "jwt",
data: regCert,
},
]
: undefined,
},
header: {
typ: "oauth-authz-req+jwt",
},
};
const header = {
...request.header,
alg: "ES256",
x5c: this.certService.getCertChain(cert),
};
const signedJwt = await this.keyChainService.signJWT(
request.payload,
header,
session.tenantId,
cert.keyId,
);
this.sessionLogger.logSession(
logContext,
"Authorization request created successfully",
{
certificateId: cert.id,
},
);
return signedJwt;
} catch (error) {
this.sessionLogger.logFlowError(logContext, error as Error, {
requestId: session.requestId,
action: "create_authorization_request",
});
throw error;
}
}
/**
* Creates a request for the OID4VP flow.
* @param requestId
* @param values
* @param tenantId
* @returns
*/
async createRequest(
requestId: string,
values: PresentationRequestOptions,
tenantId: string,
useDcApi: boolean,
origin: string,
): Promise<OfferResponse> {
const presentationConfig =
await this.presentationsService.getPresentationConfig(
requestId,
tenantId,
);
const fresh = values.session === undefined;
values.session = values.session || v4();
const request_uri_method: "get" | "post" = "get";
const cert = await this.certService.find({
tenantId: tenantId,
type: KeyUsageType.Access,
});
const certHash = this.certService.getCertHash(cert);
const params = {
client_id: "x509_hash:" + certHash,
request_uri: `${this.configService.getOrThrow<string>("PUBLIC_URL")}/${values.session}/oid4vp/request`,
request_uri_method,
};
const queryString = Object.entries(params)
.map(
([key, value]) =>
`${encodeURIComponent(key)}=${encodeURIComponent(value)}`,
)
.join("&");
// Create cross-device params with /no-redirect appended to request_uri
const crossDeviceParams = {
...params,
request_uri: `${this.configService.getOrThrow<string>("PUBLIC_URL")}/${values.session}/oid4vp/request/no-redirect`,
};
const crossDeviceQueryString = Object.entries(crossDeviceParams)
.map(
([key, value]) =>
`${encodeURIComponent(key)}=${encodeURIComponent(value)}`,
)
.join("&");
const expiresAt = new Date(
Date.now() + (presentationConfig.lifeTime ?? 300) * 1000,
);
if (fresh) {
const host = this.configService.getOrThrow<string>("PUBLIC_URL");
const clientId = "x509_hash:" + certHash;
const responseUri = useDcApi
? undefined
: `${host}/${values.session}/oid4vp`;
// Use transaction_data from options if provided, otherwise fall back to config
const transaction_data =
values.transaction_data ?? presentationConfig.transaction_data;
const session = await this.sessionService.create({
id: values.session,
parsedWebhook: values.webhook,
redirectUri:
values.redirectUri ??
presentationConfig.redirectUri ??
undefined,
tenantId,
requestId,
requestUrl: `openid4vp://?${queryString}`,
expiresAt,
useDcApi,
clientId,
responseUri,
transaction_data,
});
if (request_uri_method === "get") {
const signedJwt = await this.createAuthorizationRequest(
session.id,
origin,
);
this.sessionService.add(values.session, {
requestObject: signedJwt,
});
}
} else {
await this.sessionService.add(values.session, {
//claimsWebhook: values.webhook ?? presentationConfig.webhook,
requestUrl: `openid4vp://?${queryString}`,
expiresAt,
useDcApi,
});
}
return {
uri: queryString,
crossDeviceUri: crossDeviceQueryString,
session: values.session,
};
}
/**
* Processes the response from the wallet.
* @param body
* @param tenantId
*/
async getResponse(body: AuthorizationResponse, sessionId: string) {
const session = await this.sessionService.get(sessionId);
const decrypted = await this.encryptionService.decryptJwe<AuthResponse>(
body.response,
session.tenantId,
);
// Validate decrypted response against AuthResponse class
const res = plainToInstance(AuthResponse, decrypted);
try {
await validateOrReject(res);
} catch (errors) {
throw new BadRequestException(
`Invalid authorization response: ${JSON.stringify(errors)}`,
);
}
//for dc api the state is no longer included in the res, see: https://openid.net/specs/openid-4-verifiable-presentations-1_0.html#name-request
// Create session logging context
const logContext: SessionLogContext = {
sessionId: session.id,
tenantId: session.tenantId,
flowType: "OID4VP",
stage: "response_processing",
};
const presentationConfig =
await this.presentationsService.getPresentationConfig(
session.requestId!,
session.tenantId,
);
const webhook = session.parsedWebhook || presentationConfig.webhook;
this.sessionLogger.logFlowStart(logContext, {
action: "process_presentation_response",
hasWebhook: !!webhook,
});
try {
//TODO: load required fields from the config
const credentials = await this.presentationsService.parseResponse(
res,
presentationConfig,
session,
);
this.sessionLogger.logCredentialVerification(
logContext,
!!credentials && credentials.length > 0,
{
credentialCount: credentials?.length || 0,
nonce: session.vp_nonce,
},
);
// For DC API, state is not included in the response (per OID4VP spec).
// Use sessionId from URL path as fallback.
const effectiveSessionId = res.state ?? sessionId;
// Validate state matches sessionId when provided (prevents session hijacking)
if (res.state && res.state !== sessionId) {
throw new BadRequestException(
"State mismatch: response state does not match session ID",
);
}
//tell the auth server the result of the session.
await this.sessionService.add(effectiveSessionId, {
//TODO: not clear why it has to be any
credentials: credentials as any,
status: SessionStatus.Completed,
});
// if there a a webhook passed in the session, use it
if (webhook) {
const response = await this.webhookService
.sendWebhook({
webhook,
logContext,
session,
credentials,
expectResponse: false,
})
.catch((error) => {
this.sessionLogger.logFlowError(
logContext,
error as Error,
{
action: "webhook_callback",
},
);
});
//override it when a redirect URI is returned by the webhook
if (response?.redirectUri) {
session.redirectUri = response.redirectUri;
}
}
this.sessionLogger.logFlowComplete(logContext, {
credentialCount: credentials?.length || 0,
webhookSent: !!webhook,
});
//check if a redirect URI is defined and return it to the caller. If so, sendResponse is ignored
if (session.redirectUri) {
//TODO: not clear with the brackets are encoded
// Replace {sessionId} placeholder with actual session ID
const processedRedirectUri = decodeURIComponent(
session.redirectUri,
).replaceAll("{sessionId}", session.id);
return {
redirect_uri: processedRedirectUri,
};
}
if (body.sendResponse) {
return credentials;
}
return {};
} catch (error: any) {
this.sessionLogger.logFlowError(logContext, error as Error, {
action: "process_presentation_response",
});
throw new BadRequestException(error.message);
}
}
}