Get your free and exclusive 80-page Banking Passkey Report
Back to Overview

디지털 자격증명 발급자 구축 방법 (개발자 가이드)

OpenID4VCI 프로토콜을 사용하여 W3C 검증 가능한 자격증명 발급자를 구축하는 방법을 알아보세요. 이 단계별 가이드에서는 디지털 월렛과 호환되는 암호화 서명된 자격증명을 발급하는 Next.js 애플리케이션을 만드는 방법을 보여줍니다.

Amine

Created: August 20, 2025

Updated: August 21, 2025

Blog-Post-Header-Image

See the original blog version in English here.

DigitalCredentialsDemo Icon

Want to experience digital credentials in action?

Try Digital Credentials

1. 소개#

디지털 자격증명은 안전하고 개인정보를 보호하는 방식으로 신원과 자격을 증명하는 강력한 방법입니다. 하지만 사용자는 애초에 이 자격증명을 어떻게 얻을 수 있을까요? 바로 이 지점에서 **발급자(Issuer)**의 역할이 중요해집니다. 발급자는 정부 기관, 대학 또는 은행과 같이 신뢰할 수 있는 기관으로, 사용자에게 디지털 서명된 자격증명을 생성하고 배포하는 책임을 집니다.

이 가이드에서는 디지털 자격증명 발급자를 구축하기 위한 포괄적인 단계별 튜토리얼을 제공합니다. 우리는 검증 가능한 자격증명 발급을 위한 OpenID(OpenID4VCI) 프로토콜에 중점을 둘 것입니다. 이 프로토콜은 사용자가 발급자로부터 자격증명을 받아 디지털 월렛에 안전하게 저장하는 방법을 정의하는 최신 표준입니다.

최종 결과물은 다음과 같은 기능을 갖춘 Next.js 애플리케이션이 될 것입니다.

  1. 간단한 웹 양식을 통해 사용자 데이터를 받습니다.
  2. 안전한 일회용 자격증명 제안을 생성합니다.
  3. 사용자가 모바일 월렛으로 스캔할 수 있도록 제안을 QR 코드로 표시합니다.
  4. 사용자가 저장하고 검증을 위해 제시할 수 있는 암호화 서명된 자격증명을 발급합니다.

1.1 용어 이해하기: 디지털 자격증명 vs. 검증 가능한 자격증명#

계속 진행하기 전에, 관련이 있지만 서로 다른 두 개념의 차이점을 명확히 하는 것이 중요합니다.

  • 디지털 자격증명(일반 용어): 이는 자격증명, 인증서 또는 증명의 모든 디지털 형태를 포괄하는 광범위한 범주입니다. 여기에는 간단한 디지털 인증서, 기본 디지털 배지 또는 암호화 보안 기능이 있을 수도 있고 없을 수도 있는 전자적으로 저장된 모든 자격증명이 포함될 수 있습니다.

  • 검증 가능한 자격증명(VC - W3C 표준): 이는 W3C 검증 가능한 자격증명 데이터 모델 표준을 따르는 특정 유형의 디지털 자격증명입니다. 검증 가능한 자격증명은 암호화 서명되고, 위변조가 방지되며, 개인정보를 존중하는 자격증명으로 독립적으로 검증될 수 있습니다. 여기에는 다음과 같은 구체적인 기술적 요구사항이 포함됩니다.

    • 진위성과 무결성을 위한 암호화 서명
    • 표준화된 데이터 모델 및 형식
    • 개인정보 보호 제시 메커니즘
    • 상호 운용 가능한 검증 프로토콜

이 가이드에서는 단순한 디지털 자격증명 시스템이 아닌, W3C 표준을 따르는 검증 가능한 자격증명 발급자를 구체적으로 구축합니다. 우리가 사용하는 OpenID4VCI 프로토콜은 검증 가능한 자격증명 발급을 위해 특별히 설계되었으며, 구현할 JWT-VC 형식은 검증 가능한 자격증명을 위한 W3C 호환 형식입니다.

1.2 작동 방식#

디지털 자격증명의 핵심 기술은 세 가지 주요 주체를 포함하는 간단하지만 강력한 "신뢰 삼각형" 모델에 있습니다.

  • 발급자(Issuer): 정부 기관, 대학 또는 은행과 같은 신뢰할 수 있는 기관으로, 사용자에게 자격증명을 암호화 서명하고 발급합니다. 이 가이드에서 우리가 구축할 역할이 바로 이것입니다.
  • 소유자(Holder): 사용자로서, 자격증명을 받아 자신의 기기에 있는 개인 디지털 월렛에 안전하게 저장합니다.
  • 검증자(Verifier): 사용자의 자격증명을 확인해야 하는 애플리케이션 또는 서비스입니다.

발급 흐름은 이 생태계의 첫 번째 단계입니다. 발급자는 사용자의 정보를 확인하고 자격증명을 제공합니다. 소유자가 이 자격증명을 월렛에 가지게 되면, 검증자에게 제시하여 자신의 신원이나 자격을 증명함으로써 삼각형을 완성할 수 있습니다.

최종 애플리케이션이 작동하는 모습을 간략하게 살펴보겠습니다.

1단계: 사용자 데이터 입력 사용자가 새 자격증명을 요청하기 위해 개인 정보가 담긴 양식을 작성합니다.

2단계: 자격증명 제안 생성 애플리케이션이 안전한 자격증명 제안을 생성하고, 이를 QR 코드와 사전 승인 코드로 표시합니다.

3단계: 월렛 상호작용 사용자가 호환되는 월렛(예: Sphereon Wallet)으로 QR 코드를 스캔하고 PIN을 입력하여 발급을 승인합니다.

4단계: 자격증명 발급 완료 월렛이 새로 발급된 디지털 자격증명을 받아 저장하고, 향후 사용할 수 있도록 준비합니다.

2. 발급자 구축을 위한 사전 준비 사항#

코드를 살펴보기 전에, 필요한 기본 지식과 도구에 대해 알아보겠습니다. 이 가이드는 웹 개발 개념에 대한 기본적인 친숙도를 가정하지만, 다음 사전 준비 사항은 자격증명 발급자를 구축하는 데 필수적입니다.

2.1 프로토콜 선택#

우리의 발급자는 월렛과 발급 서비스 간의 상호 운용성을 보장하는 개방형 표준 집합을 기반으로 구축됩니다. 이 튜토리얼에서는 다음에 중점을 둘 것입니다.

표준 / 프로토콜설명
OpenID4VCI검증 가능한 자격증명 발급을 위한 OpenID. 우리가 사용할 핵심 프로토콜입니다. 사용자가 (월렛을 통해) 발급자에게 자격증명을 요청하고 받는 표준 흐름을 정의합니다.
JWT-VCJWT 기반 검증 가능한 자격증명. 우리가 발급할 자격증명의 형식입니다. 검증 가능한 자격증명을 JSON 웹 토큰(JWT)으로 인코딩하는 W3C 표준으로, 작고 웹 친화적입니다.
ISO mDocISO/IEC 18013-5. 모바일 운전면허증(mDL)을 위한 국제 표준입니다. 우리는 JWT-VC를 발급하지만, 그 안의 클레임은 mDoc 데이터 모델과 호환되도록 구성됩니다(예: eu.europa.ec.eudi.pid.1).
OAuth 2.0OpenID4VCI에서 사용하는 기본 권한 부여 프레임워크입니다. 우리는 안전하고 사용자 친화적인 자격증명 발급을 위해 특별히 설계된 특정 부여 유형인 pre-authorized_code 흐름을 구현할 것입니다.

2.1.1 인증 흐름: 사전 승인 코드 vs. 인증 코드#

OpenID4VCI는 자격증명 발급을 위해 두 가지 주요 인증 흐름을 지원합니다.

  1. 사전 승인 코드 흐름(Pre-Authorized Code Flow): 이 흐름에서는 발급자가 사용자에게 즉시 사용 가능한 단기 일회용 코드(pre-authorized_code)를 생성합니다. 그런 다음 사용자의 월렛은 이 코드를 직접 자격증명으로 교환할 수 있습니다. 이 흐름은 사용자가 이미 발급자의 웹사이트에서 인증되고 현재 있는 시나리오에 이상적이며, 리디렉션 없이 원활하고 즉각적인 발급 경험을 제공합니다.

  2. 인증 코드 흐름(Authorization Code Flow): 이것은 표준 OAuth 2.0 흐름으로, 사용자가 동의를 부여하기 위해 인증 서버로 리디렉션됩니다. 승인 후, 서버는 등록된 redirect_uriauthorization_code를 다시 보냅니다. 이 흐름은 사용자를 대신하여 발급 프로세스를 시작하는 제3자 애플리케이션에 더 적합합니다.

이 튜토리얼에서는 pre-authorized_code 흐름을 사용할 것입니다. 이 접근 방식을 선택한 이유는 더 간단하고 우리의 특정 사용 사례, 즉 사용자가 발급자의 웹사이트에서 직접 자격증명을 요청하는 경우에 더 직접적인 사용자 경험을 제공하기 때문입니다. 복잡한 리디렉션과 클라이언트 등록이 필요 없어 핵심 발급 로직을 더 쉽게 이해하고 구현할 수 있습니다.

이러한 표준의 조합을 통해 우리는 광범위한 디지털 월렛과 호환되는 발급자를 구축하고 사용자에게 안전하고 표준화된 프로세스를 보장할 수 있습니다.

2.2 기술 스택 선택#

발급자를 구축하기 위해, 우리는 검증자를 만들 때 사용했던 것과 동일한 견고하고 현대적인 기술 스택을 사용하여 일관되고 고품질의 개발자 경험을 보장할 것입니다.

2.2.1 언어: TypeScript#

우리는 프론트엔드와 백엔드 코드 모두에 TypeScript를 사용할 것입니다. 정적 타이핑은 발급자와 같은 보안에 민감한 애플리케이션에서 매우 중요하며, 일반적인 오류를 방지하고 코드의 전반적인 품질과 유지보수성을 향상시키는 데 도움이 됩니다.

2.2.2 프레임워크: Next.js#

Next.js는 풀스택 애플리케이션을 구축하는 데 원활하고 통합된 경험을 제공하기 때문에 우리가 선택한 프레임워크입니다.

  • 프론트엔드: 우리는 Next.jsReact를 사용하여 사용자가 자격증명을 요청하기 위해 데이터를 입력할 수 있는 사용자 인터페이스를 구축할 것입니다.
  • 백엔드: Next.js API Routes를 활용하여 자격증명 제안 생성부터 최종 서명된 자격증명 발급까지 OpenID4VCI 흐름을 처리하는 서버 측 엔드포인트를 만들 것입니다.

2.2.3 핵심 라이브러리#

우리의 구현은 특정 작업을 처리하기 위해 몇 가지 핵심 라이브러리에 의존할 것입니다.

  • next, react, react-dom: Next.js 애플리케이션의 핵심 라이브러리입니다.
  • mysql2: Node.jsMySQL 클라이언트로, 인증 코드와 세션 데이터를 저장하는 데 사용됩니다.
  • uuid: 고유 식별자를 생성하는 라이브러리로, pre-authorized_code 값을 만드는 데 사용할 것입니다.
  • jose: JSON 웹 서명(JWS)을 처리하는 강력한 라이브러리로, 발급하는 자격증명을 암호화 서명하는 데 사용할 것입니다.

2.3 테스트 월렛 준비하기#

발급자를 테스트하려면 OpenID4VCI 프로토콜을 지원하는 모바일 월렛이 필요합니다. 이 튜토리얼에서는 AndroidiOS 모두에서 사용할 수 있는 Sphereon Wallet을 권장합니다.

Sphereon Wallet 설치 방법:

  1. Google Play Store 또는 Apple App Store에서 월렛을 다운로드합니다.
  2. 모바일 기기에 앱을 설치합니다.
  3. 설치가 완료되면, 월렛은 QR 코드를 스캔하여 자격증명 제안을 받을 준비가 됩니다.

2.4 암호학 지식#

자격증명을 발급하는 것은 신뢰와 진위성을 보장하기 위해 기본적인 암호학 개념에 의존하는 보안에 민감한 작업입니다.

2.4.1 디지털 서명#

검증 가능한 자격증명의 핵심은 발급자에 의해 디지털 서명된 클레임 집합입니다. 이 서명은 두 가지를 보장합니다.

  • 진위성: 자격증명이 합법적인 발급자에 의해 생성되었음을 증명합니다.
  • 무결성: 발급된 이후 자격증명이 조작되지 않았음을 증명합니다.

2.4.2 공개키/개인키 암호 방식#

디지털 서명은 공개키/개인키 암호 방식을 사용하여 생성됩니다. 작동 방식은 다음과 같습니다.

  1. 발급자는 키 쌍을 가집니다: 비밀로 안전하게 보관되는 개인키와 공개적으로 사용 가능한 해당 공개키입니다.
  2. 서명: 발급자가 자격증명을 생성할 때, 개인키를 사용하여 자격증명 데이터에 대한 고유한 디지털 서명을 생성합니다.
  3. 검증: 검증자는 나중에 발급자의 공개키를 사용하여 서명을 확인할 수 있습니다. 확인이 통과되면, 검증자는 자격증명이 진짜이고 변경되지 않았음을 알 수 있습니다.

우리의 구현에서는 타원 곡선(EC) 키 쌍을 생성하고 ES256 알고리즘을 사용하여 JWT-VC에 서명할 것입니다. 공개키는 발급자의 DID(did:web)에 포함되어, 모든 검증자가 이를 발견하고 자격증명의 서명을 확인할 수 있도록 합니다.
참고: JWT에서 aud(대상) 클레임은 의도적으로 생략되었습니다. 이 자격증명은 범용으로 설계되었으며 특정 월렛에 묶이지 않기 때문입니다.
특정 대상에게 사용을 제한하려면 aud 클레임을 포함하고 그에 맞게 설정하십시오.

3. 아키텍처 개요#

우리의 발급자 애플리케이션은 프론트엔드와 백엔드 로직이 명확하게 분리된 풀스택 Next.js 프로젝트로 구축됩니다. 이 아키텍처는 서버에서 모든 보안에 민감한 작업을 처리하면서 원활한 사용자 경험을 만들 수 있게 해줍니다.
중요: SQL에 포함된 verification_sessionsverified_credentials 테이블은 이 발급자에 필요하지 않지만 완전성을 위해 포함되었습니다.

  • 프론트엔드 (src/app/issue/page.tsx): 사용자가 자격증명을 요청하기 위해 데이터를 입력할 수 있는 단일 React 페이지입니다. 발급 프로세스를 시작하기 위해 백엔드에 API 호출을 합니다.
  • 백엔드 API Routes (src/app/api/issue/...): OpenID4VCI 프로토콜을 구현하는 서버 측 엔드포인트 집합입니다.
    • /.well-known/openid-credential-issuer: 공개 메타데이터 엔드포인트입니다. 월렛이 발급자의 기능(권한 부여 서버, 토큰 엔드포인트, 자격증명 엔드포인트, 제공하는 자격증명 유형 등)을 발견하기 위해 가장 먼저 확인하는 URL입니다.
    • /.well-known/openid-configuration: 표준 OpenID Connect 검색 엔드포인트입니다. 위 엔드포인트와 밀접하게 관련되어 있지만, 이 엔드포인트는 더 광범위한 OIDC 관련 구성을 제공하며 표준 OpenID 클라이언트와의 상호 운용성을 위해 종종 필요합니다.
    • /.well-known/did.json: 우리 발급자의 DID 문서입니다. did:web 방식을 사용할 때, 이 파일은 발급자의 공개키를 게시하는 데 사용되며, 검증자는 이를 사용하여 발급된 자격증명의 서명을 확인할 수 있습니다.
    • authorize/route.ts: pre-authorized_code와 자격증명 제안을 생성합니다.
    • token/route.ts: pre-authorized_code를 액세스 토큰으로 교환합니다.
    • credential/route.ts: 최종적으로 암호화 서명된 JWT-VC를 발급합니다.
    • schemas/pid/route.ts: PID 자격증명에 대한 JSON 스키마를 노출합니다. 이를 통해 자격증명의 모든 소비자가 그 구조와 데이터 유형을 이해할 수 있습니다.
  • 라이브러리 (src/lib/):
    • database.ts: 인증 코드 및 발급자 키 저장과 같은 모든 데이터베이스 상호작용을 관리합니다.
    • crypto.ts: 키 생성 및 JWT 서명을 포함한 모든 암호화 작업을 처리합니다.

다음은 발급 흐름을 보여주는 다이어그램입니다.

4. 발급자 구축하기#

이제 표준, 프로토콜 및 아키텍처에 대한 확실한 이해를 바탕으로 발급자 구축을 시작하겠습니다.

따라 하거나 최종 코드 사용하기

지금부터 설정 및 코드 구현을 단계별로 진행하겠습니다. 만약 완성된 결과물로 바로 넘어가고 싶다면, 저희 GitHub 리포지토리에서 전체 프로젝트를 복제하여 로컬에서 실행할 수 있습니다.

git clone https://github.com/corbado/digital-credentials-example.git

4.1 프로젝트 설정하기#

먼저 새 Next.js 프로젝트를 초기화하고, 필요한 종속성을 설치한 다음, 데이터베이스를 시작하겠습니다.

4.1.1 Next.js 앱 초기화하기#

터미널을 열고 프로젝트를 만들 디렉토리로 이동한 후 다음 명령을 실행하세요. 이 프로젝트에서는 App Router, TypeScript, Tailwind CSS를 사용합니다.

npx create-next-app@latest . --ts --eslint --tailwind --app --src-dir --import-alias "@/*" --use-npm

이 명령은 현재 디렉토리에 새 Next.js 애플리케이션의 기본 구조를 만듭니다.

4.1.2 종속성 설치하기#

다음으로 JWT, 데이터베이스 연결, UUID 생성을 처리할 라이브러리를 설치해야 합니다.

npm install jose mysql2 uuid @types/uuid

이 명령은 다음을 설치합니다:

  • jose: JSON 웹 토큰(JWT) 서명 및 검증용.
  • mysql2: 데이터베이스용 MySQL 클라이언트.
  • uuid: 고유한 챌린지 문자열 생성용.
  • @types/uuid: uuid 라이브러리의 TypeScript 타입.

4.1.3 데이터베이스 시작하기#

우리 백엔드는 인증 코드, 발급 세션, 발급자 키를 저장하기 위해 MySQL 데이터베이스가 필요합니다. 이를 쉽게 설정할 수 있도록 docker-compose.yml 파일을 포함했습니다.

리포지토리를 복제했다면 docker-compose up -d를 실행하기만 하면 됩니다. 처음부터 구축하는 경우, docker-compose.yml이라는 파일을 만들고 다음 내용을 추가하세요.

services: mysql: image: mysql:8.0 restart: always environment: MYSQL_ROOT_PASSWORD: rootpassword MYSQL_DATABASE: digital_credentials MYSQL_USER: app_user MYSQL_PASSWORD: app_password ports: - "3306:3306" volumes: - mysql_data:/var/lib/mysql - ./sql/init.sql:/docker-entrypoint-initdb.d/init.sql healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] timeout: 20s retries: 10 volumes: mysql_data:

이 Docker Compose 설정에는 SQL 초기화 스크립트도 필요합니다. sql이라는 디렉토리를 만들고 그 안에 init.sql이라는 파일을 다음 내용으로 만들어 검증자와 발급자 모두에게 필요한 테이블을 설정하세요.

-- Create database if not exists CREATE DATABASE IF NOT EXISTS digital_credentials; USE digital_credentials; -- Table for storing challenges CREATE TABLE IF NOT EXISTS challenges ( id VARCHAR(36) PRIMARY KEY, challenge VARCHAR(255) NOT NULL UNIQUE, expires_at TIMESTAMP NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, used BOOLEAN DEFAULT FALSE, INDEX idx_challenge (challenge), INDEX idx_expires_at (expires_at) ); -- Table for storing verification sessions CREATE TABLE IF NOT EXISTS verification_sessions ( id VARCHAR(36) PRIMARY KEY, challenge_id VARCHAR(36), status ENUM('pending', 'verified', 'failed', 'expired') DEFAULT 'pending', presentation_data JSON, verified_at TIMESTAMP NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, FOREIGN KEY (challenge_id) REFERENCES challenges(id) ON DELETE CASCADE, INDEX idx_challenge_id (challenge_id), INDEX idx_status (status) ); -- Table for storing verified credentials data (optional) CREATE TABLE IF NOT EXISTS verified_credentials ( id VARCHAR(36) PRIMARY KEY, session_id VARCHAR(36), credential_type VARCHAR(255), issuer VARCHAR(255), subject VARCHAR(255), claims JSON, verified_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (session_id) REFERENCES verification_sessions(id) ON DELETE CASCADE, INDEX idx_session_id (session_id), INDEX idx_credential_type (credential_type) ); -- ISSUER TABLES -- Table for storing authorization codes in OpenID4VCI flow CREATE TABLE IF NOT EXISTS authorization_codes ( id VARCHAR(36) PRIMARY KEY, code VARCHAR(255) NOT NULL UNIQUE, client_id VARCHAR(255), scope VARCHAR(255), code_challenge VARCHAR(255), code_challenge_method VARCHAR(50), redirect_uri TEXT, user_pin VARCHAR(10), expires_at TIMESTAMP NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, used BOOLEAN DEFAULT FALSE, INDEX idx_code (code), INDEX idx_expires_at (expires_at) ); -- Table for storing issuance sessions CREATE TABLE IF NOT EXISTS issuance_sessions ( id VARCHAR(36) PRIMARY KEY, authorization_code_id VARCHAR(36), access_token VARCHAR(255), token_type VARCHAR(50) DEFAULT 'Bearer', expires_in INT DEFAULT 3600, c_nonce VARCHAR(255), c_nonce_expires_at TIMESTAMP, status ENUM('pending', 'authorized', 'credential_issued', 'expired', 'failed') DEFAULT 'pending', user_data JSON, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, FOREIGN KEY (authorization_code_id) REFERENCES authorization_codes(id) ON DELETE CASCADE, INDEX idx_access_token (access_token), INDEX idx_c_nonce (c_nonce), INDEX idx_status (status) ); -- Table for storing issued credentials CREATE TABLE IF NOT EXISTS issued_credentials ( id VARCHAR(36) PRIMARY KEY, session_id VARCHAR(36), credential_id VARCHAR(255), credential_type VARCHAR(255) DEFAULT 'jwt_vc', doctype VARCHAR(255) DEFAULT 'eu.europa.ec.eudi.pid.1', credential_data LONGTEXT, -- Base64 encoded mDoc credential_claims JSON, issuer_did VARCHAR(255), subject_id VARCHAR(255), issued_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, expires_at TIMESTAMP, revoked BOOLEAN DEFAULT FALSE, revoked_at TIMESTAMP NULL, FOREIGN KEY (session_id) REFERENCES issuance_sessions(id) ON DELETE CASCADE, INDEX idx_credential_id (credential_id), INDEX idx_session_id (session_id), INDEX idx_doctype (doctype), INDEX idx_subject_id (subject_id), INDEX idx_issued_at (issued_at) ); -- Table for storing issuer keys (simplified for demo) CREATE TABLE IF NOT EXISTS issuer_keys ( id VARCHAR(36) PRIMARY KEY, key_id VARCHAR(255) NOT NULL UNIQUE, key_type VARCHAR(50) NOT NULL, -- 'EC', 'RSA' algorithm VARCHAR(50) NOT NULL, -- 'ES256', 'RS256', etc. public_key TEXT NOT NULL, -- JWK format private_key TEXT NOT NULL, -- JWK format (encrypted in production) is_active BOOLEAN DEFAULT TRUE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, INDEX idx_key_id (key_id), INDEX idx_is_active (is_active) );

두 파일이 모두 준비되면, 프로젝트 루트의 터미널에서 다음을 실행하세요.

docker-compose up -d

이 명령은 백그라운드에서 MySQL 컨테이너를 시작하여 우리 애플리케이션이 사용할 수 있도록 준비합니다.

4.2 공유 라이브러리 구현하기#

API 엔드포인트를 구축하기 전에, 핵심 비즈니스 로직을 처리할 공유 라이브러리를 만들어 보겠습니다. 이 접근 방식은 API 라우트를 깔끔하고 HTTP 요청 처리에 집중하게 하는 반면, 복잡한 작업은 이 모듈에 위임합니다.

4.2.1 데이터베이스 라이브러리 (src/lib/database.ts)#

이 파일은 모든 데이터베이스 상호작용을 위한 단일 창구입니다. mysql2 라이브러리를 사용하여 MySQL 컨테이너에 연결하고, 테이블의 레코드를 생성, 읽기, 업데이트하는 내보낸 함수 집합을 제공합니다. 이 추상화 계층은 코드를 더 모듈화하고 유지보수하기 쉽게 만듭니다.

src/lib/database.ts 파일을 만들고 다음 내용을 추가하세요.

// src/lib/database.ts import mysql from "mysql2/promise"; // Database connection configuration const dbConfig = { host: process.env.DATABASE_HOST || "localhost", port: parseInt(process.env.DATABASE_PORT || "3306"), user: process.env.DATABASE_USER || "app_user", password: process.env.DATABASE_PASSWORD || "app_password", database: process.env.DATABASE_NAME || "digital_credentials", timezone: "+00:00", }; let connection: mysql.Connection | null = null; export async function getConnection(): Promise<mysql.Connection> { if (!connection) { connection = await mysql.createConnection(dbConfig); } return connection; } // Data-Access-Object (DAO) functions for each table // ... (e.g., createChallenge, getChallenge, createAuthorizationCode, etc.)

참고: 간결함을 위해 전체 DAO 함수 목록은 생략되었습니다. 전체 코드는 프로젝트 리포지토리에서 찾을 수 있습니다. 이 파일에는 챌린지, 검증 세션, 인증 코드, 발급 세션 및 발급자 키를 관리하는 함수가 포함되어 있습니다.

4.2.2 암호화 라이브러리 (src/lib/crypto.ts)#

이 파일은 모든 보안에 민감한 암호화 작업을 처리합니다. jose 라이브러리를 사용하여 키 쌍을 생성하고 JSON 웹 토큰(JWT)에 서명합니다.

키 생성 generateIssuerKeyPair 함수는 자격증명에 서명하는 데 사용될 새로운 타원 곡선 키 쌍을 생성합니다. 공개키는 JSON 웹 키(JWK) 형식으로 내보내져 did.json 문서에 게시될 수 있습니다.

// src/lib/crypto.ts import { generateKeyPair, exportJWK, SignJWT } from "jose"; export async function generateIssuerKeyPair(keyId: string, issuerDid: string) { const { publicKey, privateKey } = await generateKeyPair("ES256", { crv: "P-256", extractable: true, }); const publicKeyJWK = await exportJWK(publicKey); publicKeyJWK.kid = keyId; // Assign a unique key ID // ... (private key export and other setup) return { publicKey, privateKey, publicKeyJWK /* ... */ }; }

JWT 자격증명 생성 createJWTVerifiableCredential 함수는 발급 프로세스의 핵심입니다. 사용자의 클레임, 발급자의 키 쌍 및 기타 메타데이터를 받아 서명된 JWT-VC를 생성하는 데 사용합니다.

// src/lib/crypto.ts export async function createJWTVerifiableCredential( claims: MDocClaims, issuerKeyPair: IssuerKeyPair, subjectId: string, audience: string, ): Promise<string> { const now = Math.floor(Date.now() / 1000); const oneYear = 365 * 24 * 60 * 60; const vcPayload = { // The issuer's DID iss: issuerKeyPair.issuerDid, // The subject's (holder's) DID sub: subjectId, // The time the credential was issued (iat) and when it expires (exp) iat: now, exp: now + oneYear, // The Verifiable Credential data model vc: { "@context": [ "https://www.w3.org/2018/credentials/v1", "https://europa.eu/eudi/pid/v1", ], type: ["VerifiableCredential", "eu.europa.ec.eudi.pid.1"], issuer: issuerKeyPair.issuerDid, issuanceDate: new Date(now * 1000).toISOString(), credentialSubject: { id: subjectId, ...claims, }, }, }; // Sign the payload with the issuer's private key return await new SignJWT(vcPayload) .setProtectedHeader({ alg: issuerKeyPair.algorithm, kid: issuerKeyPair.keyId, typ: "JWT", }) .sign(issuerKeyPair.privateKey); }

이 함수는 W3C 검증 가능한 자격증명 데이터 모델에 따라 JWT 페이로드를 구성하고 발급자의 개인키로 서명하여 안전하고 검증 가능한 자격증명을 생성합니다.

4.2 Next.js 앱의 아키텍처 개요#

우리의 Next.js 애플리케이션은 같은 프로젝트의 일부임에도 불구하고 프론트엔드와 백엔드 간의 관심사를 분리하도록 구성되어 있습니다. 이는 UI 페이지와 API 엔드포인트 모두에 App Router를 활용하여 달성됩니다.

  • 프론트엔드 (src/app/issue/page.tsx): /issue 라우트의 UI를 정의하는 단일 React 페이지 컴포넌트입니다. 사용자 입력을 처리하고 백엔드 API와 통신합니다.

  • 백엔드 API Routes (src/app/api/...):

    • 검색 (.well-known/.../route.ts): 이 라우트들은 월렛과 다른 클라이언트가 발급자의 기능과 공개키를 발견할 수 있도록 공개 메타데이터 엔드포인트를 노출합니다.
    • 발급 (issue/.../route.ts): 이 엔드포인트들은 자격증명 제안 생성, 토큰 발급, 최종 자격증명 서명을 포함한 핵심 OpenID4VCI 로직을 구현합니다.
    • 스키마 (schemas/pid/route.ts): 이 라우트는 자격증명의 구조를 정의하는 JSON 스키마를 제공합니다.
  • 라이브러리 (src/lib/): 이 디렉토리에는 백엔드 전반에 걸쳐 공유되는 재사용 가능한 로직이 포함되어 있습니다.

    • database.ts: SQL 쿼리를 추상화하여 모든 데이터베이스 상호작용을 관리합니다.
    • crypto.ts: 키 생성 및 JWT 서명과 같은 모든 암호화 작업을 처리합니다.

이러한 명확한 분리는 애플리케이션을 모듈화하고 유지보수하기 쉽게 만듭니다.

참고: generateIssuerDid() 함수는 발급자 도메인과 일치하는 유효한 did:web을 반환해야 합니다.
배포 시, 검증자가 자격증명을 검증할 수 있도록 .well-known/did.json은 해당 도메인에서 HTTPS를 통해 제공되어야 합니다.

4.3 프론트엔드 구축하기#

우리의 프론트엔드는 사용자가 새 디지털 자격증명을 요청할 수 있는 간단한 양식을 제공하는 단일 React 페이지입니다. 그 책임은 다음과 같습니다.

  • 사용자 데이터(이름, 생년월일 등)를 수집합니다.
  • 이 데이터를 백엔드로 보내 자격증명 제안을 생성합니다.
  • 결과로 나온 QR 코드와 PIN을 사용자가 월렛으로 스캔할 수 있도록 표시합니다.

핵심 로직은 사용자가 양식을 제출할 때 트리거되는 handleSubmit 함수에서 처리됩니다.

// src/app/issue/page.tsx const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setLoading(true); setError(null); setCredentialOffer(null); try { // 1. Validate required fields if (!userData.given_name || !userData.family_name || !userData.birth_date) { throw new Error("Please fill in all required fields"); } // 2. Request a credential offer from the backend const response = await fetch("/api/issue/authorize", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ user_data: userData, }), }); if (!response.ok) { const errorData = await response.json(); throw new Error( errorData.error_description || "Failed to create credential offer", ); } // 3. Set the credential offer in state to display the QR code const result = await response.json(); setCredentialOffer(result); } catch (err) { const errorMessage = (err as Error).message || "Unknown error occurred"; setError(errorMessage); } finally { setLoading(false); } };

이 함수는 세 가지 주요 작업을 수행합니다.

  1. 양식 데이터 유효성 검사를 통해 모든 필수 필드가 채워졌는지 확인합니다.
  2. 사용자 데이터와 함께 /api/issue/authorize 엔드포인트로 POST 요청을 보냅니다.
  3. 백엔드에서 받은 자격증명 제안으로 컴포넌트의 상태를 업데이트하여 UI가 QR 코드와 트랜잭션 코드를 표시하도록 합니다.

파일의 나머지 부분은 양식과 QR 코드 디스플레이를 렌더링하기 위한 표준 React 코드를 포함합니다. 전체 파일은 프로젝트 리포지토리에서 볼 수 있습니다.

4.4 환경 설정 및 검색 기능 구성하기#

백엔드 API를 구축하기 전에, 환경을 구성하고 검색 엔드포인트를 설정해야 합니다. 이 .well-known 파일들은 월렛이 우리 발급자를 찾고 상호작용하는 방법을 이해하는 데 중요합니다.

4.4.1 환경 파일 생성하기#

프로젝트 루트에 .env.local이라는 파일을 만들고 다음 줄을 추가하세요. 이 URL은 모바일 월렛이 접근할 수 있도록 공개적으로 접근 가능해야 합니다. 로컬 개발의 경우 ngrok과 같은 터널링 서비스를 사용하여 localhost를 노출할 수 있습니다.

NEXT_PUBLIC_BASE_URL=http://localhost:3000

4.4.2 검색 엔드포인트 구현하기#

월렛은 표준 .well-known URL을 쿼리하여 발급자의 기능을 발견합니다. 우리는 이 세 가지 엔드포인트를 만들어야 합니다.

1. 발급자 메타데이터 (/.well-known/openid-credential-issuer)

이것은 OpenID4VCI의 기본 검색 파일입니다. 월렛에게 발급자에 대한 모든 필요한 정보, 즉 엔드포인트, 제공하는 자격증명 유형, 지원되는 암호화 알고리즘 등을 알려줍니다.

src/app/.well-known/openid-credential-issuer/route.ts 파일을 생성합니다:

// src/app/.well-known/openid-credential-issuer/route.ts import { NextResponse } from "next/server"; export async function GET() { const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3000"; const issuerMetadata = { // The issuer's unique identifier. issuer: baseUrl, // The URL of the authorization server. For simplicity, our issuer is its own authorization server. authorization_servers: [baseUrl], // The URL of the credential issuer. credential_issuer: baseUrl, // The endpoint where the wallet will POST to receive the actual credential. credential_endpoint: `${baseUrl}/api/issue/credential`, // The endpoint where the wallet exchanges an authorization code for an access token. token_endpoint: `${baseUrl}/api/issue/token`, // The endpoint for the authorization flow (not used in our pre-authorized flow, but good practice to include). authorization_endpoint: `${baseUrl}/api/issue/authorize`, // Indicates support for the pre-authorized code flow without requiring client authentication. pre_authorized_grant_anonymous_access_supported: true, // Human-readable information about the issuer. display: [ { name: "Corbado Credentials Issuer", locale: "en-US", }, ], // A list of the credential types this issuer can issue. credential_configurations_supported: { "eu.europa.ec.eudi.pid.1": { // The format of the credential (e.g., jwt_vc, mso_mdoc). format: "jwt_vc", // The specific document type, conforming to ISO mDoc standards. doctype: "eu.europa.ec.eudi.pid.1", // The OAuth 2.0 scope associated with this credential type. scope: "eu.europa.ec.eudi.pid.1", // Methods the wallet can use to prove possession of its key. cryptographic_binding_methods_supported: ["jwk"], // Signing algorithms the issuer supports for this credential. credential_signing_alg_values_supported: ["ES256"], // Proof-of-possession types the wallet can use. proof_types_supported: { jwt: { proof_signing_alg_values_supported: ["ES256", "ES384", "ES512"], }, }, // Display properties for the credential. display: [ { name: "Corbado Credential Issuer", locale: "en-US", logo: { uri: `${baseUrl}/logo.png`, alt_text: "EU Digital Identity", }, background_color: "#003399", text_color: "#FFFFFF", }, ], // A list of the claims (attributes) in the credential. claims: { "eu.europa.ec.eudi.pid.1": { given_name: { mandatory: true, display: [{ name: "Given Name", locale: "en-US" }], }, family_name: { mandatory: true, display: [{ name: "Family Name", locale: "en-US" }], }, birth_date: { mandatory: true, display: [{ name: "Date of Birth", locale: "en-US" }], }, }, }, }, }, // Authentication methods supported by the token endpoint. 'none' means public client. token_endpoint_auth_methods_supported: ["none"], // PKCE code challenge methods supported. code_challenge_methods_supported: ["S256"], // OAuth 2.0 grant types the issuer supports. grant_types_supported: [ "authorization_code", "urn:ietf:params:oauth:grant-type:pre-authorized_code", ], }; return NextResponse.json(issuerMetadata, { headers: { "Content-Type": "application/json", "Cache-Control": "no-cache, no-store, must-revalidate", Pragma: "no-cache", Expires: "0", }, }); }

2. OpenID 구성 (/.well-known/openid-configuration)

이것은 더 광범위한 구성 세부 정보를 제공하는 표준 OIDC 검색 문서입니다.

src/app/.well-known/openid-configuration/route.ts 파일을 생성합니다:

// src/app/.well-known/openid-configuration/route.ts import { NextResponse } from "next/server"; export async function GET() { const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3000"; const openidConfiguration = { // The issuer's unique identifier. credential_issuer: baseUrl, // The endpoint where the wallet will POST to receive the actual credential. credential_endpoint: `${baseUrl}/api/issue/credential`, // The endpoint for the authorization flow. authorization_endpoint: `${baseUrl}/api/issue/authorize`, // The endpoint where the wallet exchanges an authorization code for an access token. token_endpoint: `${baseUrl}/api/issue/token`, // A list of the credential types this issuer can issue. credential_configurations_supported: { "eu.europa.ec.eudi.pid.1": { format: "jwt_vc", scope: "eu.europa.ec.eudi.pid.1", cryptographic_binding_methods_supported: ["jwk"], credential_signing_alg_values_supported: ["ES256", "ES384", "ES512"], proof_types_supported: { jwt: { proof_signing_alg_values_supported: ["ES256", "ES384", "ES512"], }, }, }, }, // OAuth 2.0 grant types the issuer supports. grant_types_supported: [ "authorization_code", "urn:ietf:params:oauth:grant-type:pre-authorized_code", ], // Indicates support for the pre-authorized code flow. pre_authorized_grant_anonymous_access_supported: true, // PKCE code challenge methods supported. code_challenge_methods_supported: ["S256"], // Authentication methods supported by the token endpoint. token_endpoint_auth_methods_supported: ["none"], // OAuth 2.0 scopes the issuer supports. scopes_supported: ["eu.europa.ec.eudi.pid.1"], }; return NextResponse.json(openidConfiguration, { headers: { "Content-Type": "application/json", "Cache-Control": "no-cache, no-store, must-revalidate", Pragma: "no-cache", Expires: "0", }, }); }

3. DID 문서 (/.well-known/did.json)

이 파일은 did:web 메소드를 사용하여 발급자의 공개키를 게시하여 누구나 발급자가 발급한 자격 증명의 서명을 확인할 수 있도록 합니다.

src/app/.well-known/did.json/route.ts 파일을 생성합니다:

// src/app/.well-known/did.json/route.ts import { NextResponse } from "next/server"; import { getActiveIssuerKey } from "../../../lib/database"; import { generateIssuerDid } from "../../../lib/crypto"; export async function GET() { const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3000"; const issuerKey = await getActiveIssuerKey(); if (!issuerKey) { return NextResponse.json( { error: "No active issuer key found" }, { status: 404 }, ); } const publicKeyJWK = JSON.parse(issuerKey.public_key); const didId = generateIssuerDid(); const didDocument = { // The context defines the vocabulary used in the document. "@context": [ "https://www.w3.org/ns/did/v1", "https://w3id.org/security/suites/jws-2020/v1", ], // The DID URI, which is the unique identifier for the issuer. id: didId, // The DID controller, which is the entity that controls the DID. Here, it's the issuer itself. controller: didId, // A list of public keys that can be used to verify signatures from the issuer. verificationMethod: [ { // A unique identifier for the key, scoped to the DID. id: `${didId}#${issuerKey.key_id}`, // The type of the key. type: "JsonWebKey2020", // The DID of the key's controller. controller: didId, // The public key in JWK format. publicKeyJwk: publicKeyJWK, }, ], // Specifies which keys can be used for authentication (proving control of the DID). authentication: [`${didId}#${issuerKey.key_id}`], // Specifies which keys can be used for creating verifiable credentials. assertionMethod: [`${didId}#${issuerKey.key_id}`], // A list of services provided by the DID subject, such as the issuer endpoint. service: [ { id: `${didId}#openid-credential-issuer`, type: "OpenIDCredentialIssuer", serviceEndpoint: `${baseUrl}/.well-known/openid-credential-issuer`, }, ], }; return NextResponse.json(didDocument, { headers: { "Content-Type": "application/did+json", "Cache-Control": "no-cache, no-store, must-revalidate", Pragma: "no-cache", Expires: "0", }, }); }

왜 캐싱을 하지 않을까요? 이 세 엔드포인트 모두 캐싱을 적극적으로 방지하는 헤더(Cache-Control: no-cache, Pragma: no-cache, Expires: 0)를 반환하는 것을 알 수 있습니다. 이는 검색 문서에 대한 중요한 보안 관행입니다. 발급자 구성은 변경될 수 있습니다(예: 암호화 키가 교체될 수 있음). 만약 월렛이나 클라이언트가 did.json이나 openid-credential-issuer 파일의 이전 버전을 캐시한다면, 새로운 자격증명을 검증하거나 업데이트된 엔드포인트와 상호작용하는 데 실패할 것입니다. 클라이언트가 각 요청마다 새로운 사본을 가져오도록 강제함으로써, 우리는 항상 가장 최신 정보를 가지고 있도록 보장합니다.

4.4.3 자격증명 스키마 엔드포인트 구현하기#

공개 인프라의 마지막 부분은 자격증명 스키마 엔드포인트입니다. 이 라우트는 우리가 발급하는 PID 자격증명의 구조, 데이터 유형 및 제약 조건을 공식적으로 정의하는 JSON 스키마를 제공합니다. 월렛과 검증자는 이 스키마를 사용하여 자격증명의 내용을 검증할 수 있습니다.

src/app/api/schemas/pid/route.ts 파일을 다음 내용으로 생성합니다:

// src/app/api/schemas/pid/route.ts import { NextResponse } from "next/server"; export async function GET() { const schema = { $schema: "https://json-schema.org/draft/2020-12/schema", $id: "https://example.com/schemas/pid", // Replace with your actual domain title: "PID Credential", description: "A schema for a Verifiable Credential representing a Personal Identification Document (PID).", type: "object", properties: { credentialSubject: { type: "object", properties: { given_name: { type: "string" }, family_name: { type: "string" }, birth_date: { type: "string", format: "date" }, // ... other properties of the credential subject }, required: ["given_name", "family_name", "birth_date"], }, // ... other top-level properties of a Verifiable Credential }, }; return NextResponse.json(schema, { headers: { "Content-Type": "application/schema+json", "Access-Control-Allow-Origin": "*", // Allow cross-origin requests }, }); }

참고: PID 자격증명에 대한 JSON 스키마는 상당히 크고 상세할 수 있습니다. 간결함을 위해 전체 스키마는 생략되었습니다. 전체 파일은 프로젝트 리포지토리에서 찾을 수 있습니다.

4.5 백엔드 엔드포인트 구축하기#

프론트엔드가 준비되었으니, 이제 OpenID4VCI 흐름을 처리할 서버 측 로직이 필요합니다. 프론트엔드가 호출하는 첫 번째 엔드포인트인 /api/issue/authorize부터 시작하겠습니다.

4.5.1 /api/issue/authorize: 자격증명 제안 생성하기#

이 엔드포인트는 사용자 데이터를 받아 안전한 일회용 코드를 생성하고, 사용자의 월렛이 이해할 수 있는 credential_offer를 구성하는 역할을 합니다.

핵심 로직은 다음과 같습니다:

// src/app/api/issue/authorize/route.ts import { NextRequest, NextResponse } from "next/server"; import { v4 as uuidv4 } from "uuid"; import { createAuthorizationCode } from "@/lib/database"; export async function POST(request: NextRequest) { try { const body = await request.json(); const { user_data } = body; // 1. Validate user data if ( !user_data || !user_data.given_name || !user_data.family_name || !user_data.birth_date ) { return NextResponse.json({ error: "missing_user_data" }, { status: 400 }); } // 2. Generate a pre-authorized code and a PIN const code = uuidv4(); const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes const txCode = Math.floor(1000 + Math.random() * 9000).toString(); // 4-digit PIN // 3. Store the code and user data await createAuthorizationCode(uuidv4(), code, expiresAt); // Note: This uses an in-memory store for demo purposes only. // In production, persist data securely in a database with proper expiry. if (!(global as any).userDataStore) (global as any).userDataStore = new Map(); (global as any).userDataStore.set(code, user_data); if (!(global as any).txCodeStore) (global as any).txCodeStore = new Map(); (global as any).txCodeStore.set(code, txCode); // 4. Create the credential offer object const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3000"; const credentialOffer = { // The issuer's identifier, which is its base URL. credential_issuer: baseUrl, // An array of credential types the issuer is offering. credential_configuration_ids: ["eu.europa.ec.eudi.pid.1"], // Specifies the grant types the wallet can use. grants: { // We are using the pre-authorized code flow. "urn:ietf:params:oauth:grant-type:pre-authorized_code": { // The one-time code the wallet will exchange for a token. "pre-authorized_code": code, // Indicates that the user must enter a PIN (tx_code) to redeem the code. user_pin_required: true, }, }, }; // 5. Create the full credential offer URI (a deep link for wallets) const credentialOfferUri = `openid-credential-offer://?credential_offer=${encodeURIComponent( JSON.stringify(credentialOffer), )}`; // The final response to the frontend. return NextResponse.json({ // The deep link for the QR code. credential_offer_uri: credentialOfferUri, // The raw pre-authorized code, for display or manual entry. pre_authorized_code: code, // The 4-digit PIN the user must enter in their wallet. tx_code: txCode, }); } catch (error) { console.error("Authorization error:", error); return NextResponse.json({ error: "server_error" }, { status: 500 }); } }

이 엔드포인트의 주요 단계:

  1. 데이터 유효성 검사: 먼저 필요한 사용자 데이터가 있는지 확인합니다.
  2. 코드 생성: 고유한 pre-authorized_code(UUID)와 추가 보안 계층을 위한 4자리 tx_code(PIN)를 생성합니다.
  3. 데이터 저장: pre-authorized_code는 짧은 만료 시간과 함께 데이터베이스에 저장됩니다. 사용자 데이터와 PIN은 코드에 연결되어 인메모리에 저장됩니다.
  4. 제안 생성: OpenID4VCI 사양에 따라 credential_offer 객체를 구성합니다. 이 객체는 월렛에게 발급자 위치, 제공하는 자격증명, 그리고 그것들을 얻는 데 필요한 코드를 알려줍니다.
  5. URI 반환: 마지막으로, 딥 링크 URI(openid-credential-offer://...)를 생성하여 사용자가 볼 수 있도록 tx_code와 함께 프론트엔드로 반환합니다.

4.5.2 /api/issue/token: 코드를 토큰으로 교환하기#

사용자가 QR 코드를 스캔하고 PIN을 입력하면 월렛은 이 엔드포인트로 POST 요청을 보냅니다. 이 엔드포인트의 역할은 pre-authorized_codeuser_pin(PIN)을 검증하고, 유효하면 단기 액세스 토큰을 발급하는 것입니다.

src/app/api/issue/token/route.ts 파일을 다음 내용으로 생성합니다:

// src/app/api/issue/token/route.ts import { NextRequest, NextResponse } from "next/server"; import { v4 as uuidv4 } from "uuid"; import { getAuthorizationCode, markAuthorizationCodeAsUsed, createIssuanceSession, } from "@/lib/database"; export async function POST(request: NextRequest) { try { const formData = await request.formData(); const grant_type = formData.get("grant_type") as string; const code = formData.get("pre-authorized_code") as string; const user_pin = formData.get("user_pin") as string; // 1. Validate the grant type if (grant_type !== "urn:ietf:params:oauth:grant-type:pre-authorized_code") { return NextResponse.json( { error: "unsupported_grant_type" }, { status: 400 }, ); } // 2. Validate the pre-authorized code const authCode = await getAuthorizationCode(code); if (!authCode) { return NextResponse.json( { error: "invalid_grant", error_description: "Invalid or expired code", }, { status: 400 }, ); } // 3. Validate the PIN (tx_code) const expectedTxCode = (global as any).txCodeStore?.get(code); if (expectedTxCode !== user_pin) { return NextResponse.json( { error: "invalid_grant", error_description: "Invalid PIN" }, { status: 400 }, ); } // 4. Generate access token and c_nonce const accessToken = uuidv4(); const cNonce = uuidv4(); const cNonceExpiresAt = new Date(Date.now() + 5 * 60 * 1000); // 5 minutes // 5. Create a new issuance session const userData = (global as any).userDataStore?.get(code); await createIssuanceSession( uuidv4(), authCode.id, accessToken, cNonce, cNonceExpiresAt, userData, ); // 6. Mark the code as used and clean up temporary data await markAuthorizationCodeAsUsed(code); (global as any).txCodeStore?.delete(code); (global as any).userDataStore?.delete(code); // 7. Return the access token response return NextResponse.json({ access_token: accessToken, token_type: "Bearer", expires_in: 3600, // 1 hour c_nonce: cNonce, c_nonce_expires_in: 300, // 5 minutes }); } catch (error) { console.error("Token endpoint error:", error); return NextResponse.json({ error: "server_error" }, { status: 500 }); } }

이 엔드포인트의 주요 단계:

  1. 부여 유형 유효성 검사: 월렛이 올바른 pre-authorized_code 부여 유형을 사용하고 있는지 확인합니다.
  2. 코드 유효성 검사: pre-authorized_code가 데이터베이스에 존재하고, 만료되지 않았으며, 이전에 사용되지 않았는지 확인합니다.
  3. PIN 유효성 검사: 월렛에서 온 user_pin을 이전에 저장한 tx_code와 비교하여 사용자가 트랜잭션을 승인했는지 확인합니다.
  4. 토큰 생성: 안전한 access_tokenc_nonce(자격증명 nonce)를 생성합니다. c_nonce는 자격증명 엔드포인트에 대한 재전송 공격을 방지하기 위한 일회용 값입니다.
  5. 세션 생성: 데이터베이스에 새로운 issuance_sessions 레코드를 생성하여 액세스 토큰을 사용자 데이터에 연결합니다.
  6. 사용된 코드로 표시: 동일한 제안이 두 번 사용되는 것을 방지하기 위해 pre-authorized_code를 사용된 것으로 표시합니다.
  7. 토큰 반환: access_tokenc_nonce를 월렛에 반환합니다.

4.5.3 /api/issue/credential: 서명된 자격증명 발급하기#

이것은 최종적이고 가장 중요한 엔드포인트입니다. 월렛은 /token 엔드포인트에서 받은 액세스 토큰을 사용하여 이 라우트에 인증된 POST 요청을 보냅니다. 이 엔드포인트의 역할은 최종 검증을 수행하고, 암호화 서명된 자격증명을 생성하여 월렛에 반환하는 것입니다.

src/app/api/issue/credential/route.ts 파일을 다음 내용으로 생성합니다:

// src/app/api/issue/credential/route.ts import { NextRequest, NextResponse } from "next/server"; import { v4 as uuidv4 } from "uuid"; import { getIssuanceSessionByToken, updateIssuanceSession, createIssuedCredential, getActiveIssuerKey, } from "@/lib/database"; import { createJWTVerifiableCredential, importIssuerKeyPair, generateIssuerDid, } from "@/lib/crypto"; export async function POST(request: NextRequest) { try { // 1. Validate the Bearer token const authHeader = request.headers.get("authorization"); const accessToken = authHeader?.substring(7); const session = await getIssuanceSessionByToken(accessToken); if (!session) { return NextResponse.json({ error: "invalid_token" }, { status: 401 }); } // 2. Get the user data from the session const userData = session.user_data; if (!userData) { return NextResponse.json({ error: "missing_user_data" }, { status: 400 }); } // 3. Get the active issuer key const issuerKey = await getActiveIssuerKey(); if (!issuerKey) { // In a real application, you would have a more robust key management system. // For this demo, we can generate a key on the fly if one doesn't exist. // This part is omitted for brevity but is in the repository. return NextResponse.json( { error: "server_error", error_description: "Failed to get issuer key", }, { status: 500 }, ); } // 4. Create the JWT-VC const issuerDid = generateIssuerDid(); const keyPair = await importIssuerKeyPair( issuerKey.key_id, issuerKey.public_key, issuerKey.private_key, issuerDid, ); const subjectId = `did:example:${uuidv4()}`; const credentialData = await createJWTVerifiableCredential( userData, keyPair, subjectId, process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3000", ); // 5. Store the issued credential in the database await createIssuedCredential(/* ... credential details ... */); await updateIssuanceSession(session.id, "credential_issued"); // 6. Return the signed credential return NextResponse.json({ format: "jwt_vc", credential: credentialData, c_nonce: uuidv4(), // A new nonce for subsequent requests c_nonce_expires_in: 300, }); } catch (error) { console.error("Credential endpoint error:", error); return NextResponse.json({ error: "server_error" }, { status: 500 }); } }

이 엔드포인트의 주요 단계:

  1. 토큰 유효성 검사: Authorization 헤더에서 유효한 Bearer 토큰을 확인하고 이를 사용하여 활성 발급 세션을 조회합니다.
  2. 사용자 데이터 검색: 토큰이 생성될 때 세션에 저장되었던 사용자의 클레임 데이터를 검색합니다.
  3. 발급자 키 로드: 데이터베이스에서 발급자의 활성 서명 키를 로드합니다. 실제 환경에서는 안전한 키 관리 시스템으로 관리될 것입니다.
  4. 자격증명 생성: src/lib/crypto.tscreateJWTVerifiableCredential 헬퍼를 호출하여 JWT-VC를 구성하고 서명합니다.
  5. 발급 기록: 감사 및 폐기 목적으로 발급된 자격증명 기록을 데이터베이스에 저장합니다.
  6. 자격증명 반환: 서명된 자격증명을 JSON 응답으로 월렛에 반환합니다. 그러면 월렛은 이를 안전하게 저장할 책임이 있습니다.

5. 발급자 실행 및 다음 단계#

이제 디지털 자격증명 발급자의 완전한 엔드투엔드 구현을 마쳤습니다. 이제 로컬에서 실행하는 방법과 이를 개념 증명에서 프로덕션 준비 애플리케이션으로 발전시키기 위해 고려해야 할 사항을 알아보겠습니다.

5.1 예제 실행 방법#

  1. 리포지토리 복제:

    git clone https://github.com/corbado/digital-credentials-example.git cd digital-credentials-example
  2. 종속성 설치:

    npm install
  3. 데이터베이스 시작: Docker가 실행 중인지 확인한 다음, MySQL 컨테이너를 시작합니다:

    docker-compose up -d
  4. 환경 구성 및 터널 실행: 로컬 테스트에서 가장 중요한 단계입니다. 모바일 월렛이 인터넷을 통해 개발 머신에 연결해야 하므로, 로컬 서버를 공개 HTTPS URL로 노출해야 합니다. 이를 위해 ngrok을 사용하겠습니다.

    a. ngrok 시작:

    ngrok http 3000

    b. ngrok 출력에서 HTTPS URL을 복사합니다(예: https://random-string.ngrok.io). c. .env.local 파일을 생성하고 URL을 설정합니다:

    NEXT_PUBLIC_BASE_URL=https://<your-ngrok-url>
  5. 애플리케이션 실행:

    npm run dev

    브라우저에서 http://localhost:3000/issue를 엽니다. 이제 양식을 작성할 수 있으며, 생성된 QR 코드는 공개 ngrok URL을 정확하게 가리키므로 모바일 월렛이 연결하여 자격증명을 받을 수 있습니다.

5.2 HTTPS와 ngrok의 중요성#

디지털 자격증명 프로토콜은 보안을 최우선으로 하여 구축되었습니다. 이러한 이유로, 월렛은 거의 항상 안전하지 않은(http://) 연결을 통해 발급자에 연결하는 것을 거부합니다. 전체 프로세스는 SSL 인증서에 의해 활성화되는 안전한 HTTPS 연결에 의존합니다.

ngrok과 같은 터널 서비스는 모든 트래픽을 로컬 개발 서버로 전달하는 안전한 공개 HTTPS URL(유효한 SSL 인증서 포함)을 생성하여 두 가지 문제를 모두 해결합니다.
월렛은 HTTPS를 요구하며 안전하지 않은(http://) 엔드포인트에 연결하는 것을 거부합니다. 이것은 모바일 장치나 외부 웹훅과 상호작용해야 하는 모든 웹 서비스를 테스트하는 데 필수적인 도구입니다.

5.3 이 튜토리얼의 범위를 벗어나는 내용#

이 예제는 이해하기 쉽도록 핵심 발급 흐름에 의도적으로 초점을 맞췄습니다. 다음 주제는 범위를 벗어나는 것으로 간주됩니다.

  • 프로덕션 수준의 보안: 이 발급자는 교육용입니다. 프로덕션 시스템은 데이터베이스에 키를 저장하는 대신 안전한 키 관리 시스템(KMS), 강력한 오류 처리, 속도 제한 및 포괄적인 감사 로깅이 필요합니다.
  • 자격증명 폐기: 이 가이드는 발급된 자격증명을 폐기하는 메커니즘을 구현하지 않습니다.
    스키마에는 향후 사용을 위한 revoked 플래그가 포함되어 있지만, 여기서는 폐기 로직이 제공되지 않습니다.
  • 인증 코드 흐름: 우리는 pre-authorized_code 흐름에만 집중했습니다. authorization_code 흐름의 전체 구현은 사용자 동의 화면과 더 복잡한 OAuth 2.0 로직이 필요합니다.
  • 사용자 관리: 이 가이드에는 발급자 자체에 대한 사용자 인증이나 관리가 포함되어 있지 않습니다. 사용자가 이미 인증되었고 자격증명을 받을 권한이 있다고 가정합니다.

6. 결론#

이것으로 끝입니다! 몇 페이지의 코드로 이제 우리는 다음과 같은 기능을 갖춘 완전한 엔드투엔드 디지털 자격증명 발급자를 갖게 되었습니다.

  1. 자격증명 요청을 위한 사용자 친화적인 프론트엔드를 제공합니다.
  2. 전체 OpenID4VCI pre-authorized_code 흐름을 구현합니다.
  3. 월렛 상호 운용성을 위한 모든 필요한 검색 엔드포인트를 노출합니다.
  4. 안전하고 표준을 준수하는 JWT-검증 가능한 자격증명을 생성하고 서명합니다.

이 가이드는 견고한 기초를 제공하지만, 프로덕션 수준의 발급자는 강력한 키 관리, 인메모리 저장소 대신 영구 저장소, 자격증명 폐기 및 포괄적인 보안 강화와 같은 추가 기능이 필요합니다.
월렛 호환성도 다양합니다. 테스트에는 Sphereon Wallet이 권장되지만, 다른 월렛은 여기에 구현된 사전 승인 흐름을 지원하지 않을 수 있습니다. 그러나 핵심 구성 요소와 상호작용 흐름은 동일하게 유지됩니다. 이러한 패턴을 따르면 모든 유형의 디지털 자격증명에 대해 안전하고 상호 운용 가능한 발급자를 구축할 수 있습니다.

7. 리소스#

이 튜토리얼에서 사용되거나 참조된 주요 리소스, 사양 및 도구는 다음과 같습니다.

Add passkeys to your app in <1 hour with our UI components, SDKs & guides.

Start Free Trial

Share this article


LinkedInTwitterFacebook