Appearance
06 샘플 클라이언트
그대로 실행할 수 있는 최소 클라이언트, curl 흐름, stage 개방 전에 쓸 로컬 서버 실행법.
| 상태 | 확정 |
| 최종 확인 | 2026-09-23 |
샘플 실행
전문이 아래에 있다 — 저장소 접근 없이 파일 두 개를 만들면 된다. Node.js 22.12 이상, openid-client v6 하나만 쓴다. 발급받은 값만 바꾸면 로그인 → 콜백 → 토큰 요청 → sub 출력까지 확인된다. (멤버십 저장소 samples/client/에도 같은 파일이 있다.)
bash
mkdir membership-sample && cd membership-sample
# 아래 package.json 과 index.mjs 를 만든 뒤
npm install
ISSUER=https://stage-membership.m-box.com CLIENT_ID=<발급값> CLIENT_SECRET=<발급값> npm start
# http://localhost:3005 → [머니박스 ID로 로그인]redirect_uri http://localhost:3005/cb가 stage 클라이언트에 등록돼 있어야 한다(localhost는 http 허용).
package.json
json
{
"name": "membership-sample-client",
"private": true,
"type": "module",
"scripts": { "start": "node index.mjs" },
"dependencies": { "openid-client": "^6.1.0" }
}index.mjs (전문)
js
// 머니박스 ID 연동 샘플 — "이대로 돌려보세요".
// 환경변수: ISSUER(기본 http://localhost:8010), CLIENT_ID(remit-web), CLIENT_SECRET(remit-secret), PORT(3005)
import { createServer } from 'node:http'
import * as client from 'openid-client'
const ISSUER = process.env.ISSUER ?? 'http://localhost:8010'
const PORT = Number(process.env.PORT ?? 3005)
const REDIRECT_URI = `http://localhost:${PORT}/cb`
// HTML 에 그대로 꽂아 넣는 값(오류 메시지 등)은 이스케이프해야 한다.
const escapeHtml = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c])
let config
try {
config = await client.discovery(new URL(ISSUER), process.env.CLIENT_ID ?? 'remit-web', process.env.CLIENT_SECRET ?? 'remit-secret', undefined, {
execute: ISSUER.startsWith('http://') ? [client.allowInsecureRequests] : [],
})
} catch (e) {
console.error(`ISSUER(${ISSUER}) 에 접속할 수 없습니다: ${e.message}`)
process.exit(1)
}
const pending = new Map() // state → { codeVerifier, nonce }
createServer(async (req, res) => {
const url = new URL(req.url, `http://localhost:${PORT}`)
if (url.pathname === '/') {
res.setHeader('content-type', 'text/html; charset=utf-8')
return res.end('<h1>샘플 서비스</h1><p><a href="/login">머니박스 ID로 로그인</a></p>')
}
if (url.pathname === '/login') {
const codeVerifier = client.randomPKCECodeVerifier()
const state = client.randomState()
const nonce = client.randomNonce()
pending.set(state, { codeVerifier, nonce })
const authUrl = client.buildAuthorizationUrl(config, {
redirect_uri: REDIRECT_URI,
scope: 'openid email',
state,
nonce,
code_challenge: await client.calculatePKCECodeChallenge(codeVerifier),
code_challenge_method: 'S256',
})
res.writeHead(302, { location: authUrl.href })
return res.end()
}
if (url.pathname === '/cb') {
const state = url.searchParams.get('state')
const p = pending.get(state)
if (!p) {
res.statusCode = 400
return res.end('알 수 없는 state')
}
pending.delete(state)
try {
const tokens = await client.authorizationCodeGrant(config, url, { pkceCodeVerifier: p.codeVerifier, expectedState: state, expectedNonce: p.nonce })
const claims = tokens.claims()
// 여기서 연동 서비스는 claims.sub 로 자기 회원을 찾거나 만들고, 자기 세션을 발급한다. claims.email 은 없을 수 있다.
res.setHeader('content-type', 'text/html; charset=utf-8')
return res.end(`<h1>로그인 성공</h1><pre>${JSON.stringify({ sub: claims.sub, email: claims.email, email_verified: claims.email_verified }, null, 2)}</pre><a href="/">처음으로</a>`)
} catch (e) {
res.statusCode = 400
return res.end(`실패: ${escapeHtml(e.message)}`)
}
}
res.statusCode = 404
res.end()
}).listen(PORT, () => console.log(`샘플 클라이언트: http://localhost:${PORT}`))핵심 부분 해설
js
import * as client from 'openid-client'
// 디스커버리 한 번 — 엔드포인트·서명키 위치를 알아낸다
const config = await client.discovery(new URL(ISSUER), CLIENT_ID, CLIENT_SECRET)
// 로그인 시작
const codeVerifier = client.randomPKCECodeVerifier()
const state = client.randomState()
const nonce = client.randomNonce()
pending.set(state, { codeVerifier, nonce }) // 세션에 보관
const authUrl = client.buildAuthorizationUrl(config, {
redirect_uri: REDIRECT_URI,
scope: 'openid email',
state, nonce,
code_challenge: await client.calculatePKCECodeChallenge(codeVerifier),
code_challenge_method: 'S256',
})
res.writeHead(302, { location: authUrl.href })
// 콜백
const p = pending.get(url.searchParams.get('state')) // state 검증
const tokens = await client.authorizationCodeGrant(config, url, {
pkceCodeVerifier: p.codeVerifier, expectedState: state, expectedNonce: p.nonce,
})
const claims = tokens.claims() // 서명·iss·aud·exp·nonce 검증 완료
// claims.sub 로 회원을 찾거나 만들고, 연동 서비스 세션을 발급한다. claims.email 은 없을 수 있다client.discovery의 네 번째 인자를 비우면 client_secret_post가 기본 인증 방식이다. authorizationCodeGrant가 토큰 요청과 ID 토큰 검증을 함께 수행한다.
curl로 따라가기
라이브러리 없이 흐름을 눈으로 확인할 때.
bash
ISS=https://stage-membership.m-box.com
CID=remit-web; SEC=<시크릿>; CB=https://www.remit.example/cb
# PKCE
VERIFIER=$(openssl rand -base64 48 | tr -d '=+/')
CHALLENGE=$(printf '%s' "$VERIFIER" | openssl dgst -sha256 -binary | openssl base64 | tr '+/' '-_' | tr -d '=')
# 인가 코드 요청 — 이 URL을 브라우저에서 연다
echo "$ISS/oauth/authorize?client_id=$CID&redirect_uri=$CB&response_type=code&scope=openid%20email&state=s1&nonce=n1&code_challenge=$CHALLENGE&code_challenge_method=S256"
# 로그인 후 $CB?code=XXXX&state=s1&iss=… 로 돌아온다
# 토큰 요청
curl -s -X POST "$ISS/oauth/token" \
-d grant_type=authorization_code -d code=XXXX -d redirect_uri=$CB \
-d client_id=$CID -d client_secret=$SEC -d code_verifier=$VERIFIER
# ID 토큰 페이로드 보기(검증 아님 — 실제 코드에서는 JWKS 로 검증한다)
echo "<id_token>" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null
# 사용자 정보 조회
curl -s "$ISS/oauth/userinfo" -H "Authorization: Bearer <access_token>"
# {"sub":"rm_…","email":"hong@example.com","email_verified":true}멤버십 서버 로컬 실행
stage 개방 전이거나 네트워크 없이 개발할 때, 멤버십 서버를 개발 PC에 띄운다. stage와 같은 코드다. Docker와 Node.js 22.12+, pnpm 10이 필요하며, 멤버십 저장소(MoneyBox-Skypay/membership) 읽기 권한이 필요하다 — 담당자에게 협력사 GitHub 계정 초대를 요청한다.
bash
git clone <membership 저장소> && cd membership
pnpm install
pnpm infra:up # Postgres·Valkey 컨테이너
pnpm tsx scripts/generate-keys.ts > config.local.json
# config.local.json 을 열어 env 를 "local" 로, database.url 을
# postgresql://membership:membership@localhost:5434/membership, redis.url 을 redis://localhost:6380,
# mail 을 {"provider":"console","from":"local@m-box.com"} 으로 고친다
pnpm migrate:local
pnpm tsx scripts/seed-local.ts --config config.local.json # 클라이언트 mb-web·remit-web, 계정 hong@a.com / Secret1234
pnpm start:local # http://localhost:8010- 시드된
remit-web(시크릿remit-secret, redirecthttp://localhost:3005/cb)로 샘플을 바로 붙일 수 있다:ISSUER=http://localhost:8010 pnpm --dir samples/client start - 자기
redirect_uri로 클라이언트를 더 만들려면:pnpm tsx scripts/add-client.ts --config config.local.json --id my-web --service REMIT --name 내서비스 --redirect http://localhost:3000/cb— 시크릿이 한 번 출력된다. - 가입 인증 코드 메일은 서버 콘솔에 찍힌다.
- 로컬 issuer는
http://localhost:8010이다. 라이브러리에 http issuer 허용 옵션이 필요할 수 있다(openid-client:allowInsecureRequests).
용어
| 용어 | 뜻 |
|---|---|
sub | 서비스별 사용자 식별자. 영구 불변, 서비스마다 다름 |
| pairwise | 클라이언트(서비스)마다 다른 sub를 주는 방식. 회사 간 대조 불가 |
| PKCE | 인가 코드 가로채기 방어. code_verifier(원본)와 code_challenge(해시) 한 쌍 |
state | CSRF 방지용 임의값. 보낸 값이 그대로 돌아와야 함 |
nonce | ID 토큰 재사용 방지용 임의값. 토큰 안에 들어 있음 |
| scope | 요청하는 정보 범위. openid email offline_access |
| 디스커버리 | 서버 설정을 JSON으로 알려주는 표준 주소. /.well-known/openid-configuration |
| JWKS | 토큰 서명 검증용 공개키 목록 |
| 제3자 제공 동의 | 머니박스(운영 법인)가 연동 서비스(타 법인)에 이메일·식별자를 넘기는 데 대한 사용자 동의. 첫 연결 때 1회 |