File

src/issuer/issuance/oid4vci/authorize/interactive-authorization.service.ts

Description

Service for handling Interactive Authorization Endpoint (IAE) operations.

The IAE enables a presentation flow during credential issuance, allowing the issuer to request verifiable presentations from the wallet before issuing credentials.

Supports two interaction types:

  • openid4vp_presentation: Requests a verifiable presentation via OpenID4VP
  • redirect_to_web: Redirects to a web-based authorization flow

Index

Properties
Methods

Constructor

constructor(configService: ConfigService, cryptoService: CryptoService, sessionService: SessionService, issuanceService: IssuanceService, credentialsService: CredentialsService, oid4vpService: Oid4vpService, presentationsService: PresentationsService, authSessionRepository: Repository)
Parameters :
Name Type Optional
configService ConfigService No
cryptoService CryptoService No
sessionService SessionService No
issuanceService IssuanceService No
credentialsService CredentialsService No
oid4vpService Oid4vpService No
presentationsService PresentationsService No
authSessionRepository Repository<InteractiveAuthSessionEntity> No

Methods

Private Async advanceOrComplete
advanceOrComplete(authSession: InteractiveAuthSessionEntity, context: ReturnType, tenantId: string, origin: string)

Advance to next IAE action or complete the flow.

Parameters :
Name Type Optional
authSession InteractiveAuthSessionEntity No
context ReturnType<unknown> No
tenantId string No
origin string No
Async completeWebAuthorization
completeWebAuthorization(authSession: string, tenantId: string)

Mark a web authorization as completed. Called when user completes web-based authorization.

Parameters :
Name Type Optional
authSession string No
tenantId string No
Returns : Promise<boolean>
createErrorResponse
createErrorResponse(error: string, errorDescription?: string)

Create an error response.

Parameters :
Name Type Optional
error string No
errorDescription string Yes
Private Async createOpenid4vpInteractionResponse
createOpenid4vpInteractionResponse(authSession: string, tenantId: string, origin: string, authorizationDetails?: any[])

Create an OpenID4VP interaction response. Generates a presentation request for the wallet.

The presentation configuration is determined by:

  1. Looking up the credential_configuration_id from authorization_details
  2. Using the first iaeAction of type openid4vp_presentation from that credential config
  3. Falling back to the first available presentation configuration
Parameters :
Name Type Optional
authSession string No
tenantId string No
origin string No
authorizationDetails any[] Yes
Private Async createOpenid4vpInteractionResponseForConfig
createOpenid4vpInteractionResponseForConfig(authSession: string, tenantId: string, origin: string, presentationConfigId: string, label?: string)

Create an OpenID4VP interaction response for a specific presentation config.

Parameters :
Name Type Optional
authSession string No
tenantId string No
origin string No
presentationConfigId string No
label string Yes
Private createRedirectToWebResponse
createRedirectToWebResponse(authSession: string, tenantId: string)

Create a redirect_to_web interaction response. Generates a PAR request URI for web-based authorization.

Parameters :
Name Type Optional
authSession string No
tenantId string No
Private createRedirectToWebResponseForUrl
createRedirectToWebResponseForUrl(authSession: string, tenantId: string, url?: string, description?: string)

Create a redirect_to_web interaction response for a specific URL.

Parameters :
Name Type Optional
authSession string No
tenantId string No
url string Yes
description string Yes
Private Async executeIaeAction
executeIaeAction(authSession: string, tenantId: string, origin: string, action: IaeAction, supportedTypes: InteractionType[])

Execute a specific IAE action.

Parameters :
Name Type Optional
authSession string No
tenantId string No
origin string No
action IaeAction No
supportedTypes InteractionType[] No
getAuthorizationServerMetadata
getAuthorizationServerMetadata(tenantId: string)

Get the authorization server metadata for interactive authorization.

Parameters :
Name Type Optional Description
tenantId string No

The tenant ID

Returns : AuthorizationServerMetadata

The authorization server metadata

Private Async handleCodeVerifier
handleCodeVerifier(codeVerifier: string, authSession: InteractiveAuthSessionEntity)

Handle PKCE code_verifier for redirect_to_web flow. Validates the code_verifier and returns success or error.

Parameters :
Name Type Optional
codeVerifier string No
authSession InteractiveAuthSessionEntity No
Private Async handleFollowUpRequest
handleFollowUpRequest(request: InteractiveAuthFollowUpRequest, tenantId: string, origin: string)

Handle a follow-up interactive authorization request. Validates the submitted interaction response and either:

  • Advances to the next IAE action, or
  • Issues an authorization code if all steps are complete.
Parameters :
Name Type Optional
request InteractiveAuthFollowUpRequest No
tenantId string No
origin string No
Private Async handleInitialRequest
handleInitialRequest(request: InteractiveAuthInitialRequest, tenantId: string, origin: string, dpop?: literal type, clientAttestation?: literal type)

Handle an initial interactive authorization request. Creates an auth session and returns the appropriate interaction response.

If the credential config has iaeActions defined, those are used. Otherwise, falls back to the default behavior based on wallet-supported types.

Parameters :
Name Type Optional
request InteractiveAuthInitialRequest No
tenantId string No
origin string No
dpop literal type Yes
clientAttestation literal type Yes
Private Async handleOpenid4vpResponse
handleOpenid4vpResponse(openid4vpResponse: string, authSession: InteractiveAuthSessionEntity)

Handle OpenID4VP response from wallet. Validates and stores the VP data, returns success or error.

Parameters :
Name Type Optional
openid4vpResponse string No
authSession InteractiveAuthSessionEntity No
Async handleRequest
handleRequest(body: InteractiveAuthorizationRequestDto, req: Request, tenantId: string, origin: string)

Handle an interactive authorization request. Routes to the appropriate handler based on request type.

Parameters :
Name Type Optional Description
body InteractiveAuthorizationRequestDto No

The request body

req Request No

The Express request

tenantId string No

The tenant ID

origin string No

The request origin

The interactive authorization response

Private Async issueAuthorizationCode
issueAuthorizationCode(authSession: InteractiveAuthSessionEntity)

Issue an authorization code after successful interaction.

Parameters :
Name Type Optional
authSession InteractiveAuthSessionEntity No
Private parseAuthorizationDetails
parseAuthorizationDetails(authDetails: any)

Parse authorization_details which can be a string or array.

Parameters :
Name Type Optional
authDetails any No
Returns : [] | undefined
parseRequest
parseRequest(body: InteractiveAuthorizationRequestDto, req: Request, tenantId: string)

Parse an interactive authorization request. Determines if the request is an initial or follow-up request.

Parameters :
Name Type Optional Description
body InteractiveAuthorizationRequestDto No

The request body

req Request No

The Express request

tenantId string No

The tenant ID

Parsed request with type indication

Private parseSessionContext
parseSessionContext(authSession: InteractiveAuthSessionEntity)

Parse session context data.

Parameters :
Name Type Optional
authSession InteractiveAuthSessionEntity No
Returns : literal type
Private Async processCodeVerifierFollow
processCodeVerifierFollow(codeVerifier: string, authSession: InteractiveAuthSessionEntity, context: ReturnType, tenantId: string, origin: string)

Process code_verifier follow-up response.

Parameters :
Name Type Optional
codeVerifier string No
authSession InteractiveAuthSessionEntity No
context ReturnType<unknown> No
tenantId string No
origin string No
Private Async processOpenid4vpFollow
processOpenid4vpFollow(openid4vpResponse: string, authSession: InteractiveAuthSessionEntity, context: ReturnType, tenantId: string, origin: string)

Process OpenID4VP follow-up response.

Parameters :
Name Type Optional
openid4vpResponse string No
authSession InteractiveAuthSessionEntity No
context ReturnType<unknown> No
tenantId string No
origin string No
Private Async validateAuthSession
validateAuthSession(authSession: string, tenantId: string)

Validate and retrieve an auth session.

Parameters :
Name Type Optional
authSession string No
tenantId string No
Private Async verifyPkce
verifyPkce(codeVerifier: string, codeChallenge: string, method?: string)

Verify PKCE code_verifier against code_challenge.

Parameters :
Name Type Optional
codeVerifier string No
codeChallenge string No
method string Yes
Returns : Promise<boolean>

Properties

Private Readonly logger
Type : unknown
Default value : new Logger(InteractiveAuthorizationService.name)
import { randomUUID } from "node:crypto";
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { InjectRepository } from "@nestjs/typeorm";
import {
    type AuthorizationServerMetadata,
    Jwk,
    Oauth2ErrorCodes,
} from "@openid4vc/oauth2";
import type { Request } from "express";
import { Repository } from "typeorm";
import { v4 } from "uuid";
import { CryptoService } from "../../../../crypto/crypto.service";
import { SessionService } from "../../../../session/session.service";
import { Oid4vpService } from "../../../../verifier/oid4vp/oid4vp.service";
import { PresentationsService } from "../../../../verifier/presentations/presentations.service";
import { CredentialsService } from "../../../configuration/credentials/credentials.service";
import {
    type IaeAction,
    type IaeActionOpenid4vpPresentation,
    IaeActionType,
} from "../../../configuration/credentials/entities/iae-action.dto";
import { IssuanceService } from "../../../configuration/issuance/issuance.service";
import {
    InteractiveAuthSessionEntity,
    InteractiveAuthSessionStatus,
} from "../entities/interactive-auth-session.entity";
import {
    InteractionType,
    InteractiveAuthorizationRequestDto,
    InteractiveAuthorizationRequestType,
    type InteractiveAuthorizationResponse,
    type Openid4vpRequestDto,
} from "./dto/interactive-authorization.dto";

/**
 * Initial interactive authorization request from wallet.
 */
interface InteractiveAuthInitialRequest {
    response_type: string;
    client_id: string;
    interaction_types_supported: string;
    redirect_uri?: string;
    scope?: string;
    code_challenge?: string;
    code_challenge_method?: string;
    authorization_details?: any[];
    state?: string;
    issuer_state?: string;
}

/**
 * Follow-up interactive authorization request from wallet.
 */
interface InteractiveAuthFollowUpRequest {
    auth_session: string;
    openid4vp_response?: string;
    code_verifier?: string;
}

/**
 * Parsed result from interactive authorization request.
 */
interface ParsedInteractiveAuthorizationRequest {
    type: InteractiveAuthorizationRequestType;
    request: InteractiveAuthInitialRequest | InteractiveAuthFollowUpRequest;
    dpop?: {
        jwk: Jwk;
        jwt: string;
    };
    clientAttestation?: {
        clientAttestationJwt: string;
        clientAttestationPopJwt: string;
    };
}

/**
 * Service for handling Interactive Authorization Endpoint (IAE) operations.
 *
 * The IAE enables a presentation flow during credential issuance, allowing
 * the issuer to request verifiable presentations from the wallet before
 * issuing credentials.
 *
 * Supports two interaction types:
 * - openid4vp_presentation: Requests a verifiable presentation via OpenID4VP
 * - redirect_to_web: Redirects to a web-based authorization flow
 */
@Injectable()
export class InteractiveAuthorizationService {
    private readonly logger = new Logger(InteractiveAuthorizationService.name);

    constructor(
        private readonly configService: ConfigService,
        private readonly cryptoService: CryptoService,
        private readonly sessionService: SessionService,
        private readonly issuanceService: IssuanceService,
        private readonly credentialsService: CredentialsService,
        private readonly oid4vpService: Oid4vpService,
        private readonly presentationsService: PresentationsService,
        @InjectRepository(InteractiveAuthSessionEntity)
        private readonly authSessionRepository: Repository<InteractiveAuthSessionEntity>,
    ) {}

    /**
     * Get the authorization server metadata for interactive authorization.
     * @param tenantId The tenant ID
     * @returns The authorization server metadata
     */
    getAuthorizationServerMetadata(
        tenantId: string,
    ): AuthorizationServerMetadata {
        const authServer = `${this.configService.getOrThrow<string>("PUBLIC_URL")}/${tenantId}`;
        return {
            issuer: authServer,
            authorization_endpoint: `${authServer}/authorize`,
            token_endpoint: `${authServer}/authorize/token`,
            interactive_authorization_endpoint: `${authServer}/authorize/interactive`,
        };
    }

    /**
     * Parse an interactive authorization request.
     * Determines if the request is an initial or follow-up request.
     *
     * @param body The request body
     * @param req The Express request
     * @param tenantId The tenant ID
     * @returns Parsed request with type indication
     */
    parseRequest(
        body: InteractiveAuthorizationRequestDto,
        req: Request,
        tenantId: string,
    ): ParsedInteractiveAuthorizationRequest {
        // Check for client attestation headers
        const clientAttestationJwt = req.headers["oauth-client-attestation"] as
            | string
            | undefined;
        const clientAttestationPopJwt = req.headers[
            "oauth-client-attestation-pop"
        ] as string | undefined;
        const clientAttestation =
            clientAttestationJwt && clientAttestationPopJwt
                ? { clientAttestationJwt, clientAttestationPopJwt }
                : undefined;

        // Check for DPoP header
        const dpopHeader = req.headers["dpop"] as string | undefined;
        // DPoP parsing would require JWT decoding - simplified for now
        const dpop = dpopHeader
            ? { jwt: dpopHeader, jwk: {} as Jwk }
            : undefined;

        // Determine request type based on presence of auth_session vs interaction_types_supported
        if (body.auth_session) {
            // This is a follow-up request
            return {
                type: InteractiveAuthorizationRequestType.FOLLOW_UP,
                request: {
                    auth_session: body.auth_session,
                    openid4vp_response: body.openid4vp_response,
                    code_verifier: body.code_verifier,
                },
            };
        }

        if (!body.interaction_types_supported) {
            throw new BadRequestException(
                "Missing required parameter: interaction_types_supported",
            );
        }

        // This is an initial request
        return {
            type: InteractiveAuthorizationRequestType.INITIAL,
            request: {
                response_type: body.response_type || "code",
                client_id: body.client_id || "",
                interaction_types_supported: body.interaction_types_supported,
                redirect_uri: body.redirect_uri,
                scope: body.scope,
                code_challenge: body.code_challenge,
                code_challenge_method: body.code_challenge_method,
                authorization_details: this.parseAuthorizationDetails(
                    body.authorization_details,
                ),
                state: body.state,
                issuer_state: body.issuer_state,
            },
            dpop,
            clientAttestation,
        };
    }

    /**
     * Parse authorization_details which can be a string or array.
     */
    private parseAuthorizationDetails(authDetails: any): any[] | undefined {
        if (!authDetails) return undefined;
        if (typeof authDetails === "string") {
            try {
                return JSON.parse(authDetails);
            } catch {
                return undefined;
            }
        }
        return authDetails;
    }

    /**
     * Handle an interactive authorization request.
     * Routes to the appropriate handler based on request type.
     *
     * @param body The request body
     * @param req The Express request
     * @param tenantId The tenant ID
     * @param origin The request origin
     * @returns The interactive authorization response
     */
    async handleRequest(
        body: InteractiveAuthorizationRequestDto,
        req: Request,
        tenantId: string,
        origin: string,
    ): Promise<InteractiveAuthorizationResponse> {
        try {
            const parsed = this.parseRequest(body, req, tenantId);

            if (parsed.type === InteractiveAuthorizationRequestType.INITIAL) {
                return await this.handleInitialRequest(
                    parsed.request as InteractiveAuthInitialRequest,
                    tenantId,
                    origin,
                    parsed.dpop,
                    parsed.clientAttestation,
                );
            } else {
                return await this.handleFollowUpRequest(
                    parsed.request as InteractiveAuthFollowUpRequest,
                    tenantId,
                    origin,
                );
            }
        } catch (error) {
            this.logger.error(
                "Error handling interactive authorization request:",
                error,
            );
            if (error instanceof BadRequestException) {
                return {
                    error: Oauth2ErrorCodes.InvalidRequest,
                    error_description: error.message,
                };
            }
            return {
                error: Oauth2ErrorCodes.ServerError,
                error_description:
                    error instanceof Error ? error.message : "Unknown error",
            };
        }
    }

    /**
     * Handle an initial interactive authorization request.
     * Creates an auth session and returns the appropriate interaction response.
     *
     * If the credential config has iaeActions defined, those are used.
     * Otherwise, falls back to the default behavior based on wallet-supported types.
     */
    private async handleInitialRequest(
        request: InteractiveAuthInitialRequest,
        tenantId: string,
        origin: string,
        dpop?: { jwk: Jwk; jwt: string },
        clientAttestation?: {
            clientAttestationJwt: string;
            clientAttestationPopJwt: string;
        },
    ): Promise<InteractiveAuthorizationResponse> {
        // Validate required fields
        if (!request.client_id) {
            return {
                error: Oauth2ErrorCodes.InvalidRequest,
                error_description: "Missing required parameter: client_id",
            };
        }

        // Parse supported interaction types
        const supportedTypes = request.interaction_types_supported
            .split(",")
            .map((t) => t.trim()) as InteractionType[];

        // Load IAE actions from credential config if available
        let iaeActions: IaeAction[] | undefined;
        if (request.authorization_details?.length) {
            const credentialConfigId =
                request.authorization_details[0]?.credential_configuration_id;
            if (credentialConfigId) {
                const credentialConfig =
                    await this.credentialsService.getCredentialConfig(
                        credentialConfigId,
                        tenantId,
                    );
                if (credentialConfig?.iaeActions?.length) {
                    iaeActions = credentialConfig.iaeActions;
                    this.logger.debug(
                        `Loaded ${iaeActions.length} IAE actions for credential ${credentialConfigId}`,
                    );
                }
            }
        }

        // Create auth session
        const authSession = v4();
        const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes

        // Store the auth session
        await this.authSessionRepository.save({
            authSession,
            tenantId,
            clientId: request.client_id,
            redirectUri: request.redirect_uri,
            scope: request.scope,
            codeChallenge: request.code_challenge,
            codeChallengeMethod: request.code_challenge_method,
            issuerState: request.issuer_state,
            state: request.state,
            authorizationDetails: request.authorization_details
                ? JSON.stringify(request.authorization_details)
                : undefined,
            interactionTypesSupported: request.interaction_types_supported,
            dpopJwk: dpop?.jwk ? JSON.stringify(dpop.jwk) : undefined,
            iaeActions: iaeActions ? JSON.stringify(iaeActions) : undefined,
            currentStepIndex: 0,
            completedStepsData: JSON.stringify([]),
            expiresAt,
            status: InteractiveAuthSessionStatus.Pending,
        });

        // If IAE actions are defined, execute the first one
        if (iaeActions?.length) {
            return this.executeIaeAction(
                authSession,
                tenantId,
                origin,
                iaeActions[0],
                supportedTypes,
            );
        }

        // Fallback: Check if we should use OpenID4VP presentation (legacy behavior)
        if (supportedTypes.includes(InteractionType.OPENID4VP_PRESENTATION)) {
            return await this.createOpenid4vpInteractionResponse(
                authSession,
                tenantId,
                origin,
                request.authorization_details,
            );
        }

        // Check if we should use redirect_to_web
        if (supportedTypes.includes(InteractionType.REDIRECT_TO_WEB)) {
            return this.createRedirectToWebResponse(authSession, tenantId);
        }

        // No supported interaction type found
        return {
            error: Oauth2ErrorCodes.InvalidRequest,
            error_description: "No supported interaction type available",
        };
    }

    /**
     * Execute a specific IAE action.
     */
    private async executeIaeAction(
        authSession: string,
        tenantId: string,
        origin: string,
        action: IaeAction,
        supportedTypes: InteractionType[],
    ): Promise<InteractiveAuthorizationResponse> {
        switch (action.type) {
            case IaeActionType.OPENID4VP_PRESENTATION: {
                if (
                    !supportedTypes.includes(
                        InteractionType.OPENID4VP_PRESENTATION,
                    )
                ) {
                    return {
                        error: Oauth2ErrorCodes.InvalidRequest,
                        error_description:
                            "Wallet does not support openid4vp_presentation interaction type",
                    };
                }
                return this.createOpenid4vpInteractionResponseForConfig(
                    authSession,
                    tenantId,
                    origin,
                    action.presentationConfigId,
                    action.label,
                );
            }
            case IaeActionType.REDIRECT_TO_WEB: {
                if (!supportedTypes.includes(InteractionType.REDIRECT_TO_WEB)) {
                    return {
                        error: Oauth2ErrorCodes.InvalidRequest,
                        error_description:
                            "Wallet does not support redirect_to_web interaction type",
                    };
                }
                return this.createRedirectToWebResponseForUrl(
                    authSession,
                    tenantId,
                    action.url,
                    action.description,
                );
            }
            default:
                return {
                    error: Oauth2ErrorCodes.InvalidRequest,
                    error_description: `Unknown IAE action type: ${(action as any).type}`,
                };
        }
    }

    /**
     * Handle a follow-up interactive authorization request.
     * Validates the submitted interaction response and either:
     * - Advances to the next IAE action, or
     * - Issues an authorization code if all steps are complete.
     */
    private async handleFollowUpRequest(
        request: InteractiveAuthFollowUpRequest,
        tenantId: string,
        origin: string,
    ): Promise<InteractiveAuthorizationResponse> {
        // Retrieve and validate the auth session
        const validationResult = await this.validateAuthSession(
            request.auth_session,
            tenantId,
        );
        // Check if it's an error response (has 'error' property) vs entity (has 'id' property)
        if ("error" in validationResult) {
            return validationResult;
        }
        const authSessionEntity =
            validationResult as InteractiveAuthSessionEntity;

        // Parse session context
        const sessionContext = this.parseSessionContext(authSessionEntity);

        // Handle OpenID4VP response
        if (request.openid4vp_response) {
            return this.processOpenid4vpFollow(
                request.openid4vp_response,
                authSessionEntity,
                sessionContext,
                tenantId,
                origin,
            );
        }

        // Handle code_verifier for redirect_to_web flow
        if (request.code_verifier) {
            return this.processCodeVerifierFollow(
                request.code_verifier,
                authSessionEntity,
                sessionContext,
                tenantId,
                origin,
            );
        }

        return {
            error: Oauth2ErrorCodes.InvalidRequest,
            error_description: "Missing openid4vp_response or code_verifier",
        };
    }

    /**
     * Validate and retrieve an auth session.
     */
    private async validateAuthSession(
        authSession: string,
        tenantId: string,
    ): Promise<
        InteractiveAuthSessionEntity | InteractiveAuthorizationResponse
    > {
        const authSessionEntity = await this.authSessionRepository.findOne({
            where: { authSession, tenantId },
        });

        if (!authSessionEntity) {
            return {
                error: Oauth2ErrorCodes.InvalidRequest,
                error_description: "Invalid or expired auth_session",
            };
        }

        if (authSessionEntity.expiresAt < new Date()) {
            await this.authSessionRepository.remove(authSessionEntity);
            return {
                error: Oauth2ErrorCodes.InvalidRequest,
                error_description: "Auth session has expired",
            };
        }

        return authSessionEntity;
    }

    /**
     * Parse session context data.
     */
    private parseSessionContext(authSession: InteractiveAuthSessionEntity): {
        iaeActions: IaeAction[] | undefined;
        completedStepsData: any[];
        supportedTypes: InteractionType[];
    } {
        return {
            iaeActions: authSession.iaeActions
                ? JSON.parse(authSession.iaeActions)
                : undefined,
            completedStepsData: authSession.completedStepsData
                ? JSON.parse(authSession.completedStepsData)
                : [],
            supportedTypes: authSession.interactionTypesSupported
                .split(",")
                .map((t) => t.trim()) as InteractionType[],
        };
    }

    /**
     * Process OpenID4VP follow-up response.
     */
    private async processOpenid4vpFollow(
        openid4vpResponse: string,
        authSession: InteractiveAuthSessionEntity,
        context: ReturnType<typeof this.parseSessionContext>,
        tenantId: string,
        origin: string,
    ): Promise<InteractiveAuthorizationResponse> {
        const result = await this.handleOpenid4vpResponse(
            openid4vpResponse,
            authSession,
        );
        if (!("success" in result)) return result;

        context.completedStepsData.push({
            stepIndex: authSession.currentStepIndex,
            type: IaeActionType.OPENID4VP_PRESENTATION,
            data: openid4vpResponse,
            completedAt: new Date().toISOString(),
        });

        return this.advanceOrComplete(authSession, context, tenantId, origin);
    }

    /**
     * Process code_verifier follow-up response.
     */
    private async processCodeVerifierFollow(
        codeVerifier: string,
        authSession: InteractiveAuthSessionEntity,
        context: ReturnType<typeof this.parseSessionContext>,
        tenantId: string,
        origin: string,
    ): Promise<InteractiveAuthorizationResponse> {
        const result = await this.handleCodeVerifier(codeVerifier, authSession);
        if (!("success" in result)) return result;

        context.completedStepsData.push({
            stepIndex: authSession.currentStepIndex,
            type: IaeActionType.REDIRECT_TO_WEB,
            completedAt: new Date().toISOString(),
        });

        return this.advanceOrComplete(authSession, context, tenantId, origin);
    }

    /**
     * Advance to next IAE action or complete the flow.
     */
    private async advanceOrComplete(
        authSession: InteractiveAuthSessionEntity,
        context: ReturnType<typeof this.parseSessionContext>,
        tenantId: string,
        origin: string,
    ): Promise<InteractiveAuthorizationResponse> {
        const { iaeActions, completedStepsData, supportedTypes } = context;

        // Check if there are more actions
        if (
            iaeActions &&
            authSession.currentStepIndex < iaeActions.length - 1
        ) {
            const nextStepIndex = authSession.currentStepIndex + 1;
            await this.authSessionRepository.update(authSession.id, {
                currentStepIndex: nextStepIndex,
                completedStepsData: JSON.stringify(completedStepsData),
                status: InteractiveAuthSessionStatus.Pending,
            });

            return this.executeIaeAction(
                authSession.authSession,
                tenantId,
                origin,
                iaeActions[nextStepIndex],
                supportedTypes,
            );
        }

        // All steps complete
        await this.authSessionRepository.update(authSession.id, {
            completedStepsData: JSON.stringify(completedStepsData),
            status: InteractiveAuthSessionStatus.AllStepsCompleted,
        });

        return this.issueAuthorizationCode(authSession);
    }

    /**
     * Handle OpenID4VP response from wallet.
     * Validates and stores the VP data, returns success or error.
     */
    private async handleOpenid4vpResponse(
        openid4vpResponse: string,
        authSession: InteractiveAuthSessionEntity,
    ): Promise<{ success: true } | InteractiveAuthorizationResponse> {
        try {
            const vpResponse = JSON.parse(openid4vpResponse);

            // Update session status
            await this.authSessionRepository.update(authSession.id, {
                status: "presentation_received",
                presentationData: openid4vpResponse,
            });

            // If there's an issuer_state, also update the main session
            if (authSession.issuerState) {
                try {
                    await this.sessionService.add(authSession.issuerState, {
                        credentials: vpResponse,
                    });
                } catch (error) {
                    this.logger.warn(
                        "Could not update main session with presentation data:",
                        error,
                    );
                }
            }

            // Return success indicator (auth code will be issued by advanceOrComplete)
            return { success: true };
        } catch (error) {
            this.logger.error("Failed to process OpenID4VP response:", error);
            return {
                error: Oauth2ErrorCodes.InvalidRequest,
                error_description: "Invalid openid4vp_response format",
            };
        }
    }

    /**
     * Handle PKCE code_verifier for redirect_to_web flow.
     * Validates the code_verifier and returns success or error.
     */
    private async handleCodeVerifier(
        codeVerifier: string,
        authSession: InteractiveAuthSessionEntity,
    ): Promise<{ success: true } | InteractiveAuthorizationResponse> {
        // Verify PKCE
        if (!authSession.codeChallenge) {
            return {
                error: Oauth2ErrorCodes.InvalidRequest,
                error_description:
                    "No code_challenge was provided in initial request",
            };
        }

        const verifierValid = await this.verifyPkce(
            codeVerifier,
            authSession.codeChallenge,
            authSession.codeChallengeMethod,
        );

        if (!verifierValid) {
            return {
                error: Oauth2ErrorCodes.InvalidGrant,
                error_description: "Invalid code_verifier",
            };
        }

        // Check if web authorization was completed
        if (authSession.status !== "web_auth_completed") {
            return {
                error: Oauth2ErrorCodes.AccessDenied,
                error_description: "Web authorization not completed",
            };
        }

        // Return success indicator (auth code will be issued by advanceOrComplete)
        return { success: true };
    }

    /**
     * Create an OpenID4VP interaction response.
     * Generates a presentation request for the wallet.
     *
     * The presentation configuration is determined by:
     * 1. Looking up the credential_configuration_id from authorization_details
     * 2. Using the first iaeAction of type openid4vp_presentation from that credential config
     * 3. Falling back to the first available presentation configuration
     */
    private async createOpenid4vpInteractionResponse(
        authSession: string,
        tenantId: string,
        origin: string,
        authorizationDetails?: any[],
    ): Promise<InteractiveAuthorizationResponse> {
        try {
            // Determine which presentation configuration to use
            let configId: string | undefined;

            // Try to get required presentation from credential configuration's IAE actions
            if (authorizationDetails?.length) {
                const credentialConfigId =
                    authorizationDetails[0]?.credential_configuration_id;
                if (credentialConfigId) {
                    const credentialConfig =
                        await this.credentialsService.getCredentialConfig(
                            credentialConfigId,
                            tenantId,
                        );
                    // Look for first openid4vp_presentation action in iaeActions
                    const presentationAction =
                        credentialConfig?.iaeActions?.find(
                            (a): a is IaeActionOpenid4vpPresentation =>
                                a.type === IaeActionType.OPENID4VP_PRESENTATION,
                        );
                    if (presentationAction) {
                        configId = presentationAction.presentationConfigId;
                        this.logger.debug(
                            `Using presentation config ${configId} from IAE actions for credential ${credentialConfigId}`,
                        );
                    }
                }
            }

            // Fall back to first available presentation configuration
            if (!configId) {
                const configs =
                    await this.presentationsService.getPresentationConfigs(
                        tenantId,
                    );
                configId = configs[0]?.id;
            }

            if (!configId) {
                return {
                    error: Oauth2ErrorCodes.ServerError,
                    error_description:
                        "No presentation configuration available",
                };
            }

            return this.createOpenid4vpInteractionResponseForConfig(
                authSession,
                tenantId,
                origin,
                configId,
            );
        } catch (error) {
            this.logger.error("Failed to create OpenID4VP interaction:", error);
            return {
                error: Oauth2ErrorCodes.ServerError,
                error_description: `Failed to create presentation request: ${error instanceof Error ? error.message : "Unknown error"}`,
            };
        }
    }

    /**
     * Create an OpenID4VP interaction response for a specific presentation config.
     */
    private async createOpenid4vpInteractionResponseForConfig(
        authSession: string,
        tenantId: string,
        origin: string,
        presentationConfigId: string,
        label?: string,
    ): Promise<InteractiveAuthorizationResponse> {
        try {
            // Create the presentation request
            const presentationResult = await this.oid4vpService.createRequest(
                presentationConfigId,
                { session: authSession },
                tenantId,
                false, // useDcApi
                origin,
            );

            // Store the session for later use
            await this.sessionService.create({
                id: authSession,
                tenantId,
                requestId: presentationConfigId,
            });

            // Create the OpenID4VP request object
            const openid4vpRequest: Openid4vpRequestDto = {
                request: presentationResult.uri,
            };

            return {
                status: "require_interaction",
                type: "openid4vp_presentation",
                auth_session: authSession,
                openid4vp_request: openid4vpRequest,
            };
        } catch (error) {
            this.logger.error("Failed to create OpenID4VP interaction:", error);
            return {
                error: Oauth2ErrorCodes.ServerError,
                error_description: `Failed to create presentation request: ${error instanceof Error ? error.message : "Unknown error"}`,
            };
        }
    }

    /**
     * Create a redirect_to_web interaction response.
     * Generates a PAR request URI for web-based authorization.
     */
    private createRedirectToWebResponse(
        authSession: string,
        tenantId: string,
    ): InteractiveAuthorizationResponse {
        return this.createRedirectToWebResponseForUrl(authSession, tenantId);
    }

    /**
     * Create a redirect_to_web interaction response for a specific URL.
     */
    private createRedirectToWebResponseForUrl(
        authSession: string,
        tenantId: string,
        url?: string,
        description?: string,
    ): InteractiveAuthorizationResponse {
        // Create a request_uri for PAR-based web authorization
        const requestUri = `urn:ietf:params:oauth:request_uri:${randomUUID()}`;
        const expiresIn = 600; // 10 minutes

        // Store PAR data in the auth session (async)
        this.authSessionRepository.update(
            { authSession, tenantId },
            {
                requestUri,
                parExpiresAt: new Date(Date.now() + expiresIn * 1000),
            },
        );

        return {
            status: "require_interaction",
            type: "redirect_to_web",
            auth_session: authSession,
            request_uri: requestUri,
            expires_in: expiresIn,
        };
    }

    /**
     * Issue an authorization code after successful interaction.
     */
    private async issueAuthorizationCode(
        authSession: InteractiveAuthSessionEntity,
    ): Promise<InteractiveAuthorizationResponse> {
        const authorizationCode = randomUUID();

        // Update the session with the authorization code
        await this.authSessionRepository.update(authSession.id, {
            authorizationCode,
            status: "code_issued",
        });

        // If there's an issuer_state, also update the main session
        if (authSession.issuerState) {
            try {
                await this.sessionService.add(authSession.issuerState, {
                    authorization_code: authorizationCode,
                });
            } catch (error) {
                this.logger.warn(
                    "Could not update main session with authorization code:",
                    error,
                );
            }
        }

        return {
            status: "ok",
            code: authorizationCode,
        };
    }

    /**
     * Verify PKCE code_verifier against code_challenge.
     */
    private async verifyPkce(
        codeVerifier: string,
        codeChallenge: string,
        method?: string,
    ): Promise<boolean> {
        if (method === "S256" || !method) {
            const { createHash } = await import("node:crypto");
            const hash = createHash("sha256")
                .update(codeVerifier)
                .digest("base64url");
            return hash === codeChallenge;
        } else if (method === "plain") {
            return codeVerifier === codeChallenge;
        }
        return false;
    }

    /**
     * Mark a web authorization as completed.
     * Called when user completes web-based authorization.
     */
    async completeWebAuthorization(
        authSession: string,
        tenantId: string,
    ): Promise<boolean> {
        const result = await this.authSessionRepository.update(
            { authSession, tenantId },
            { status: "web_auth_completed" },
        );
        return (result.affected ?? 0) > 0;
    }

    /**
     * Create an error response.
     */
    createErrorResponse(
        error: string,
        errorDescription?: string,
    ): InteractiveAuthorizationResponse {
        return {
            error,
            error_description: errorDescription,
        };
    }
}

results matching ""

    No results matching ""