본문으로 건너뛰기

GesundheitsID 연동 구현 가이드

개요

이 문서는 독일 GesundheitsID(건강ID) 시스템과의 연동 구현에 대한 가이드라인을 제공합니다. GesundheitsID는 독일의 디지털 의료 생태계에서 사용자 인증 및 본인 확인을 위한 표준 ID 시스템입니다.

GesundheitsID 연동 흐름

계정 연동(Linking) 프로세스

GesundheitsID 로그인 프로세스

구현 가이드

1. 환경 설정

필요한 환경 변수

GESUNDHEITSID_CLIENT_ID=<클라이언트 ID>
GESUNDHEITSID_CLIENT_SECRET=<클라이언트 시크릿>
GESUNDHEITSID_REDIRECT_URI=<리다이렉트 URI>
GESUNDHEITSID_AUTH_ENDPOINT=<인증 엔드포인트>
GESUNDHEITSID_TOKEN_ENDPOINT=<토큰 엔드포인트>
GESUNDHEITSID_USERINFO_ENDPOINT=<사용자 정보 엔드포인트>
GESUNDHEITSID_JWKS_URI=<JWK 세트 URI>

필요한 패키지 설치

npm install --save openid-client jsonwebtoken jose uuid

2. GesundheitsID 클라이언트 설정

import { Issuer, Client } from 'openid-client';

@Injectable()
export class GesundheitsIDService {
private client: Client;

constructor(
private readonly configService: ConfigService,
private readonly userRepository: UserRepository,
private readonly gesundheitsIdRepository: GesundheitsIDRepository
) {
this.initializeClient();
}

private async initializeClient(): Promise<void> {
try {
const issuer = await Issuer.discover(
this.configService.get<string>('GESUNDHEITSID_DISCOVERY_ENDPOINT')
);

this.client = new issuer.Client({
client_id: this.configService.get<string>('GESUNDHEITSID_CLIENT_ID'),
client_secret: this.configService.get<string>('GESUNDHEITSID_CLIENT_SECRET'),
redirect_uris: [this.configService.get<string>('GESUNDHEITSID_REDIRECT_URI')],
response_types: ['code'],
token_endpoint_auth_method: 'client_secret_basic'
});
} catch (error) {
throw new Error(`GesundheitsID client initialization failed: ${error.message}`);
}
}
}

3. 연동 초기화 구현

@Controller('auth/gesundheitsid')
export class GesundheitsIDController {
constructor(private readonly gesundheitsIDService: GesundheitsIDService) {}

@Post('link/initialize')
@UseGuards(AuthGuard)
async initializeLink(
@Req() request: AuthenticatedRequest
): Promise<ApiResponse<GesundheitsIDAuthUrlDto>> {
const userId = request.user.id;
const result = await this.gesundheitsIDService.initializeLink(userId);

return {
data: {
authUrl: result.authUrl,
state: result.state
}
};
}
}

@Injectable()
export class GesundheitsIDService {
// ... 기존 코드 ...

async initializeLink(userId: string): Promise<{ authUrl: string; state: string }> {
// state 생성 및 저장
const state = crypto.randomBytes(16).toString('hex');
const nonce = crypto.randomBytes(16).toString('hex');

// 세션 저장
await this.gesundheitsIdRepository.saveSession({
userId,
state,
nonce,
createdAt: new Date(),
type: 'LINK'
});

// 인증 URL 생성
const authUrl = this.client.authorizationUrl({
scope: 'openid profile email',
state,
nonce,
prompt: 'consent'
});

return { authUrl, state };
}
}

4. 연동 완료 구현

@Controller('auth/gesundheitsid')
export class GesundheitsIDController {
// ... 기존 코드 ...

@Post('link/complete')
@UseGuards(AuthGuard)
async completeLink(
@Req() request: AuthenticatedRequest,
@Body() dto: CompleteLinkDto
): Promise<ApiResponse<GesundheitsIDLinkResultDto>> {
const userId = request.user.id;
const result = await this.gesundheitsIDService.completeLink(userId, dto.code, dto.state);

return {
data: {
success: result.success,
linkedAt: result.linkedAt,
gesundheitsId: result.gesundheitsId
}
};
}
}

@Injectable()
export class GesundheitsIDService {
// ... 기존 코드 ...

async completeLink(
userId: string,
code: string,
state: string
): Promise<GesundheitsIDLinkResult> {
// 세션 조회 및 검증
const session = await this.gesundheitsIdRepository.findSessionByUserIdAndState(userId, state);

if (!session || session.type !== 'LINK') {
throw new BadRequestException({
code: 8001,
message: 'INVALID_STATE',
detail: '유효하지 않거나 만료된 연동 세션입니다.'
});
}

// 세션 삭제 (일회용)
await this.gesundheitsIdRepository.deleteSession(session.id);

try {
// 토큰 교환
const tokenSet = await this.client.callback(
this.configService.get<string>('GESUNDHEITSID_REDIRECT_URI'),
{ code, state },
{ nonce: session.nonce }
);

// ID 토큰 검증 및 파싱
const idToken = tokenSet.id_token;
const claims = this.client.validateIdToken(tokenSet);

// GesundheitsID 정보 저장
const linkedAt = new Date();
await this.gesundheitsIdRepository.saveLink({
userId,
gesundheitsId: claims.sub,
linkedAt,
idToken,
accessToken: tokenSet.access_token,
refreshToken: tokenSet.refresh_token,
expiresAt: new Date(Date.now() + tokenSet.expires_in * 1000),
tokenType: tokenSet.token_type
});

return {
success: true,
linkedAt,
gesundheitsId: claims.sub
};
} catch (error) {
throw new BadRequestException({
code: 8002,
message: 'LINK_FAILED',
detail: `GesundheitsID 연동에 실패했습니다: ${error.message}`
});
}
}
}

5. 연동 상태 조회 구현

@Controller('auth/gesundheitsid')
export class GesundheitsIDController {
// ... 기존 코드 ...

@Get('link')
@UseGuards(AuthGuard)
async getLinkStatus(
@Req() request: AuthenticatedRequest
): Promise<ApiResponse<GesundheitsIDLinkStatusDto>> {
const userId = request.user.id;
const status = await this.gesundheitsIDService.getLinkStatus(userId);

return {
data: status
};
}
}

@Injectable()
export class GesundheitsIDService {
// ... 기존 코드 ...

async getLinkStatus(userId: string): Promise<GesundheitsIDLinkStatusDto> {
const link = await this.gesundheitsIdRepository.findLinkByUserId(userId);

if (!link) {
return {
linked: false
};
}

return {
linked: true,
linkedAt: link.linkedAt,
gesundheitsId: link.gesundheitsId
};
}
}

6. 연동 해제 구현

@Controller('auth/gesundheitsid')
export class GesundheitsIDController {
// ... 기존 코드 ...

@Post('unlink')
@UseGuards(AuthGuard)
async unlink(
@Req() request: AuthenticatedRequest
): Promise<ApiResponse<GesundheitsIDUnlinkResultDto>> {
const userId = request.user.id;
const result = await this.gesundheitsIDService.unlink(userId);

return {
data: {
success: result.success,
unlinkedAt: result.unlinkedAt
}
};
}
}

@Injectable()
export class GesundheitsIDService {
// ... 기존 코드 ...

async unlink(userId: string): Promise<GesundheitsIDUnlinkResult> {
const link = await this.gesundheitsIdRepository.findLinkByUserId(userId);

if (!link) {
throw new NotFoundException({
code: 8003,
message: 'GESUNDHEITSID_NOT_LINKED',
detail: 'GesundheitsID가 연동되어 있지 않습니다.'
});
}

// 연동 삭제
await this.gesundheitsIdRepository.deleteLink(link.id);

// 필요한 경우 원격 토큰 취소
try {
await this.client.revoke(link.accessToken);
// 리프레시 토큰이 있으면 함께 취소
if (link.refreshToken) {
await this.client.revoke(link.refreshToken);
}
} catch (error) {
// 원격 토큰 취소 실패는 로깅만 하고 계속 진행
console.error('Failed to revoke GesundheitsID tokens:', error);
}

const unlinkedAt = new Date();

return {
success: true,
unlinkedAt
};
}
}

7. GesundheitsID 로그인 구현

@Controller('auth/gesundheitsid')
export class GesundheitsIDController {
// ... 기존 코드 ...

@Post('login/initialize')
async initializeLogin(): Promise<ApiResponse<GesundheitsIDAuthUrlDto>> {
const result = await this.gesundheitsIDService.initializeLogin();

return {
data: {
authUrl: result.authUrl,
state: result.state
}
};
}

@Post('login/complete')
async completeLogin(
@Body() dto: CompleteLoginDto,
@Req() request: Request
): Promise<ApiResponse<LoginResultDto>> {
const ipAddress = request.ip;
const userAgent = request.headers['user-agent'];

const result = await this.gesundheitsIDService.completeLogin(
dto.code,
dto.state,
{ ipAddress, userAgent }
);

return {
data: {
accessToken: result.accessToken,
refreshToken: result.refreshToken,
expiresIn: result.expiresIn,
userId: result.userId,
name: result.name
}
};
}
}

@Injectable()
export class GesundheitsIDService {
// ... 기존 코드 ...

async initializeLogin(): Promise<{ authUrl: string; state: string }> {
// state 생성
const state = crypto.randomBytes(16).toString('hex');
const nonce = crypto.randomBytes(16).toString('hex');

// 세션 저장
await this.gesundheitsIdRepository.saveSession({
userId: null, // 로그인은 사용자가 없는 상태에서 시작
state,
nonce,
createdAt: new Date(),
type: 'LOGIN'
});

// 인증 URL 생성
const authUrl = this.client.authorizationUrl({
scope: 'openid profile email',
state,
nonce
});

return { authUrl, state };
}

async completeLogin(
code: string,
state: string,
metadata: { ipAddress: string; userAgent: string }
): Promise<LoginResult> {
// 세션 조회 및 검증
const session = await this.gesundheitsIdRepository.findSessionByState(state);

if (!session || session.type !== 'LOGIN') {
throw new BadRequestException({
code: 8004,
message: 'INVALID_STATE',
detail: '유효하지 않거나 만료된 로그인 세션입니다.'
});
}

// 세션 삭제 (일회용)
await this.gesundheitsIdRepository.deleteSession(session.id);

try {
// 토큰 교환
const tokenSet = await this.client.callback(
this.configService.get<string>('GESUNDHEITSID_REDIRECT_URI'),
{ code, state },
{ nonce: session.nonce }
);

// ID 토큰 검증 및 파싱
const claims = this.client.validateIdToken(tokenSet);
const gesundheitsId = claims.sub;

// GesundheitsID로 사용자 조회
const link = await this.gesundheitsIdRepository.findLinkByGesundheitsId(gesundheitsId);

if (!link) {
throw new NotFoundException({
code: 8005,
message: 'GESUNDHEITSID_NOT_LINKED',
detail: '연동된 GesundheitsID를 찾을 수 없습니다. 먼저 계정을 생성하고 연동해주세요.'
});
}

// 사용자 조회
const user = await this.userRepository.findById(link.userId);

if (!user) {
throw new NotFoundException({
code: 8006,
message: 'USER_NOT_FOUND',
detail: '연동된 사용자 계정을 찾을 수 없습니다.'
});
}

// 토큰 발급
const tokens = await this.authService.generateTokens(
user.id,
{ ipAddress: metadata.ipAddress, userAgent: metadata.userAgent }
);

return {
userId: user.id,
name: user.name,
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
expiresIn: tokens.expiresIn
};
} catch (error) {
if (error.code) {
// 이미 포맷된 오류는 그대로 throw
throw error;
}

throw new BadRequestException({
code: 8007,
message: 'LOGIN_FAILED',
detail: `GesundheitsID 로그인에 실패했습니다: ${error.message}`
});
}
}
}

데이터베이스 스키마

CREATE TABLE gesundheitsid_links (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id),
gesundheitsid VARCHAR(255) NOT NULL UNIQUE,
linked_at TIMESTAMP WITH TIME ZONE NOT NULL,
id_token TEXT NOT NULL,
access_token TEXT NOT NULL,
refresh_token TEXT,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
token_type VARCHAR(50) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE gesundheitsid_sessions (
id UUID PRIMARY KEY,
user_id UUID REFERENCES users(id),
state VARCHAR(64) NOT NULL UNIQUE,
nonce VARCHAR(64) NOT NULL,
type VARCHAR(20) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT (NOW() + INTERVAL '10 minutes')
);

CREATE INDEX idx_gesundheitsid_links_user_id ON gesundheitsid_links(user_id);
CREATE INDEX idx_gesundheitsid_links_gesundheitsid ON gesundheitsid_links(gesundheitsid);
CREATE INDEX idx_gesundheitsid_sessions_state ON gesundheitsid_sessions(state);
CREATE INDEX idx_gesundheitsid_sessions_expires_at ON gesundheitsid_sessions(expires_at);

보안 고려사항

  • 모든 상태 파라미터(state)는 CSRF 공격 방지를 위해 암호학적으로 안전한 난수로 생성해야 합니다.
  • ID 토큰 검증 시 nonce, iss, aud, exp 등의 클레임을 철저히 검증해야 합니다.
  • 클라이언트 시크릿은 노출되지 않도록 환경 변수로 관리해야 합니다.
  • 액세스 토큰 및 ID 토큰은 저장 시 데이터베이스 암호화를 적용하는 것이 좋습니다.
  • 세션 데이터는 짧은 유효 기간(10분 내외)을 가지도록 설계하고, 사용 후 즉시 삭제해야 합니다.

오류 처리

  • GesundheitsID 서버 연결 오류, 토큰 검증 오류 등 다양한 오류 상황에 대한 적절한 처리가 필요합니다.
  • 사용자에게 표시되는 오류 메시지는 기술적 세부 정보를 최소화하고 사용자 친화적인 메시지를 제공해야 합니다.

테스트 전략

  • OpenID Connect 프로토콜 흐름을 테스트하기 위한 모의 GesundheitsID 서버 구현
  • 연동 및 로그인 시나리오에 대한 단위 테스트 및 통합 테스트
  • 상태 관리 및 오류 처리에 대한 테스트