⚠️ 작성 중인 초안입니다 — 검토·확정 전이며, 각 문서의 "미확정" 항목은 정책이 아닙니다.
Skip to content

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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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, redirect http://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(해시) 한 쌍
stateCSRF 방지용 임의값. 보낸 값이 그대로 돌아와야 함
nonceID 토큰 재사용 방지용 임의값. 토큰 안에 들어 있음
scope요청하는 정보 범위. openid email offline_access
디스커버리서버 설정을 JSON으로 알려주는 표준 주소. /.well-known/openid-configuration
JWKS토큰 서명 검증용 공개키 목록
제3자 제공 동의머니박스(운영 법인)가 연동 서비스(타 법인)에 이메일·식별자를 넘기는 데 대한 사용자 동의. 첫 연결 때 1회