GesundheitsID 명세
개요
이 문서는 독일 GesundheitsID(건강ID) 시스템과의 연동을 위한 기술적 명세를 정의합니다. GesundheitsID는 독일의 디지털 의료 생태계에서 표준화된 사용자 인증 및 본인 확인 시스템으로, OpenID Connect 프로토콜을 기반으로 구현되어 있습니다.
GesundheitsID 시스템 개요
GesundheitsID는 다음과 같은 주요 기능을 제공합니다:
- 사용자 인증 (Authentication)
- 본인 확인 (Identity Verification)
- 속성 제공 (Attribute Provision)
- 동의 관리 (Consent Management)
OpenID Connect 프로토콜
GesundheitsID는 OpenID Connect 1.0 프로토콜을 구현하고 있으며, 다음과 같은 흐름을 지원합니다:
- 인증 코드 흐름 (Authorization Code Flow)
- 암시적 흐름 (Implicit Flow)
- 하이브리드 흐름 (Hybrid Flow)
당사 시스템에서는 보안상의 이유로 인증 코드 흐름만 사용합니다.
엔드포인트 명세
디스커버리 엔드포인트
GET https://gesundheitsid.example.de/.well-known/openid-configuration
이 엔드포인트를 통해 GesundheitsID 서비스의 엔드포인트 정보를 자동으로 조회할 수 있습니다.
인증 엔드포인트
GET https://gesundheitsid.example.de/auth
이 엔드포인트는 사용자를 GesundheitsID 인증 페이지로 리다이렉트하는 데 사용됩니다.
토큰 엔드포인트
POST https://gesundheitsid.example.de/token
이 엔드포인트는 인증 코드를 액세스 토큰, ID 토큰, 리프레시 토큰으로 교환하는 데 사용됩니다.
사용자 정보 엔드포인트
GET https://gesundheitsid.example.de/userinfo
이 엔드포인트는 사용자 정보를 조회하는 데 사용됩니다.
JWK 세트 엔드포인트
GET https://gesundheitsid.example.de/jwks
이 엔드포인트는 ID 토큰 검증에 사용되는 공개 키를 조회하는 데 사용됩니다.
클라이언트 등록
GesundheitsID 시스템과 연동하기 위해서는 사전에 클라이언트 등록이 필요합니다. 등록 시 다음 정보가 필요합니다:
- 클라이언트 이름
- 리다이렉트 URI
- 로고 이미지
- 개인정보처리방침 URL
- 요청 스코프
등록이 완료되면 다음 정보가 발급됩니다:
- 클라이언트 ID
- 클라이언트 시크릿
인증 요청 파라미터
GesundheitsID 인증 요청 시 다음 파라미터를 포함해야 합니다:
| 파라미터 | 설명 | 필수 여부 |
|---|---|---|
| client_id | 클라이언트 ID | 필수 |
| redirect_uri | 인증 완료 후 리다이렉트할 URI | 필수 |
| response_type | 'code' (인증 코드 흐름) | 필수 |
| scope | 요청 스코프 (최소 'openid') | 필수 |
| state | CSRF 방지를 위한 상태값 | 필수 |
| nonce | 재생 공격 방지를 위한 임의 값 | 필수 |
| acr_values | 인증 컨텍스트 참조 값 | 선택 |
| prompt | 인증 프롬프트 동작 ('none', 'login', 'consent') | 선택 |
| max_age | 최대 인증 기간 (초) | 선택 |
| ui_locales | UI 언어 설정 (예: 'de', 'en') | 선택 |
토큰 응답 형식
토큰 엔드포인트 응답은 다음과 같은 형식입니다:
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"id_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."
}
ID 토큰 형식
ID 토큰은 JWT(JSON Web Token) 형식으로, 다음과 같은 클레임을 포함합니다:
| 클레임 | 설명 | 필수 여부 |
|---|---|---|
| iss | 발급자 (issuer) - GesundheitsID 인증 서버 URL | 필수 |
| sub | 주체 (subject) - 사용자의 GesundheitsID | 필수 |
| aud | 대상 (audience) - 클라이언트 ID | 필수 |
| exp | 만료 시간 (expiration time) | 필수 |
| iat | 발급 시간 (issued at) | 필수 |
| auth_time | 사용자 인증 시간 | 필수 |
| nonce | 요청 시 전달한 nonce 값 | 필수 |
| acr | 인증 컨텍스트 참조 값 | 조건부 |
| amr | 인증 방법 참조 값 | 조건부 |
| azp | 권한 부여 당사자 | 조건부 |
| name | 사용자 이름 | 선택 |
| given_name | 사용자 이름 (first name) | 선택 |
| family_name | 사용자 성 (last name) | 선택 |
| 사용자 이메일 | 선택 | |
| email_verified | 이메일 검증 여부 | 선택 |
| birthdate | 생년월일 | 선택 |
지원 스코프
GesundheitsID는 다음 스코프를 지원합니다:
| 스코프 | 설명 |
|---|---|
| openid | OpenID Connect 기본 스코프 (필수) |
| profile | 사용자 프로필 정보 (이름, 성별, 생년월일 등) |
| 사용자 이메일 주소 | |
| phone | 사용자 전화번호 |
| address | 사용자 주소 |
| health.insurance | 사용자 건강보험 정보 |
오류 코드
GesundheitsID 인증 및 토큰 요청 시 발생할 수 있는 주요 오류 코드:
| 오류 코드 | 설명 |
|---|---|
| invalid_request | 잘못된 요청 파라미터 |
| unauthorized_client | 승인되지 않은 클라이언트 |
| access_denied | 사용자가 접근을 거부함 |
| unsupported_response_type | 지원하지 않는 응답 타입 |
| invalid_scope | 잘못된 스코프 |
| server_error | 서버 오류 |
| temporarily_unavailable | 일시적으로 서비스 사용 불가 |
| invalid_grant | 잘못된 인증 코드 또는 리프레시 토큰 |
| invalid_client | 클라이언트 인증 실패 |
보안 고려사항
인증 코드 흐름
인증 코드 흐름은 다음과 같은 이유로 가장 안전한 방식입니다:
- 클라이언트 시크릿이 백엔드에서만 사용됨
- 액세스 토큰이 프론트엔드로 노출되지 않음
- PKCE(Proof Key for Code Exchange) 확장을 지원하여 추가 보안 제공
PKCE 구현
PKCE를 구현하려면 다음 단계를 따라야 합니다:
- 코드 인증(code_verifier) 생성: 최소 43자 이상의 무작위 문자열
- 코드 챌린지(code_challenge) 생성: code_verifier를 SHA-256으로 해싱하고 Base64-URL로 인코딩
- 인증 요청 시 code_challenge 및 code_challenge_method 파라미터 포함
- 토큰 요청 시 code_verifier 파라미터 포함
토큰 검증
ID 토큰 검증 시 다음 사항을 확인해야 합니다:
- 서명 검증 (JWK 세트 엔드포인트에서 공개 키 조회)
- 발급자(iss) 검증
- 대상(aud) 검증
- 만료 시간(exp) 검증
- 발급 시간(iat) 검증
- nonce 검증
구현 예시
인증 요청 URL 생성
function generateAuthUrl(state: string, nonce: string): string {
const params = new URLSearchParams({
client_id: 'YOUR_CLIENT_ID',
redirect_uri: 'YOUR_REDIRECT_URI',
response_type: 'code',
scope: 'openid profile email',
state,
nonce,
prompt: 'consent'
});
return `https://gesundheitsid.example.de/auth?${params.toString()}`;
}
토큰 교환
async function exchangeCodeForTokens(code: string, state: string, nonce: string): Promise<TokenSet> {
const response = await fetch('https://gesundheitsid.example.de/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': `Basic ${btoa(`${CLIENT_ID}:${CLIENT_SECRET}`)}`
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: 'YOUR_REDIRECT_URI'
}).toString()
});
if (!response.ok) {
throw new Error('Token exchange failed');
}
const tokenSet = await response.json();
// ID 토큰 검증
validateIdToken(tokenSet.id_token, nonce);
return tokenSet;
}
ID 토큰 검증
async function validateIdToken(idToken: string, expectedNonce: string): Promise<void> {
// JWK 세트 가져오기
const jwksResponse = await fetch('https://gesundheitsid.example.de/jwks');
const jwks = await jwksResponse.json();
// 토큰 디코딩 및 검증
const decodedToken = jwt.decode(idToken, { complete: true });
const kid = decodedToken.header.kid;
// 키 찾기
const key = jwks.keys.find(k => k.kid === kid);
if (!key) {
throw new Error('Signing key not found');
}
// 서명 검증
const isValid = jwt.verify(idToken, key, {
algorithms: ['RS256'],
audience: CLIENT_ID,
issuer: 'https://gesundheitsid.example.de'
});
if (!isValid) {
throw new Error('ID token validation failed');
}
const payload = jwt.decode(idToken);
// nonce 검증
if (payload.nonce !== expectedNonce) {
throw new Error('Invalid nonce');
}
// 만료 시간 검증
if (Date.now() / 1000 > payload.exp) {
throw new Error('Token expired');
}
}