OAuth 2.0 에서는 발렛 키(Access Token)를 얻는 과정이었다. 이로인해 OAuth 2.0만으로는 부족한 점이 있는데 그것이 바로 "이 사용자가 정확히 누구인가?"에 대한 표준화된 정보이다.
이를 해결하기 위해 OAuth 2.0 위에 신원 확인 계층을 얹은 것이 바로 OIDC(OpenID Connect)이다. 오늘은 OIDC의 핵심인 ID Token과 이를 실무에서 구현할 때 사용하는 JWT(JSON Web Token)를 라이브러리 없이 파헤쳐보려한다.
OAuth 2.0 vs OIDC: 무엇이 다를까?
많은 개발자가 이 둘을 혼용하지만, 목적 자체가 다르다.
- OAuth 2.0 (Authorization): 인가(권한 부여)가 목적이다. "내 사진첩에 접근할 수 있게 해줘"라는 요청에 대해 '액세스 토큰'을 주지만 토큰만 봐서는 사용자의 이름이나 이메일을 알 수 없다.
- OIDC (Authentication): 인증(신원 확인)이 목적이다. OAuth 2.0의 흐름을 그대로 따르되, 추가로 사용자의 신원 정보가 담긴 'ID 토큰'을 제공한다는 것이다.
쉽게 말해, OIDC = OAuth 2.0 + Identity(Identity Layer) 이다.

JWT (JSON Web Token) 이해하기
OIDC에서 신원 정보를 전달하는 표준 형식이 바로 JWT입니다. JWT는 점(.)으로 구분된 세 부분으로 나눠져있다.
- Header: 토큰의 타입(JWT)과 사용된 암호화 알고리즘(HS256, RS256 등)이 있다.
- Payload: 실제 데이터(Claims)가 있으며 사용자 ID(sub), 발급자(iss), 만료시간(exp) 등이 있다.
- Signature: 헤더와 페이로드를 비밀키로 서명한 값이다. 토큰이 변조되지 않았음을 증명한다.

인가 서버 구현: ID Token(JWT) 발급하기
저번에 만든 auth-server.js에 JWT 발급 로직을 추가하고 라이브러리 대신 Node.js 내장 crypto 모듈을 사용하여 서명을 생성한다.
JWT 생성 함수
const crypto = require('crypto');
// Base64Url 인코딩 함수 (JWT 표준)
function base64UrlEncode(str) {
return Buffer.from(str)
.toString('base64')
.replace(/=/g, '')
.replace(/\+/g, '-')
.replace(/\//g, '_');
}
// ID Token 생성 함수
function createIDToken(user, clientId) {
const header = JSON.stringify({ alg: 'HS256', typ: 'JWT' });
const payload = JSON.stringify({
iss: 'http://localhost:4000', // 발급자
sub: user, // 사용자 식별자
aud: clientId, // 수신자 (우리 클라이언트 앱)
iat: Math.floor(Date.now() / 1000), // 발급 시간
exp: Math.floor(Date.now() / 1000) + 3600, // 만료 시간 (1시간)
name: '테스트 유저',
email: 'test@example.com'
});
const encodedHeader = base64UrlEncode(header);
const encodedPayload = base64UrlEncode(payload);
// HMAC SHA256 서명 생성
const secret = 'secret_key_123'; // 서버만 아는 비밀키
const signature = crypto
.createHmac('sha256', secret)
.update(`${encodedHeader}.${encodedPayload}`)
.digest('base64')
.replace(/=/g, '')
.replace(/\+/g, '-')
.replace(/\//g, '_');
return `${encodedHeader}.${encodedPayload}.${signature}`;
}
인가 서버 토큰 엔드포인트 수정
이제 /oauth/token 경로에서 access_token뿐만 아니라 id_token도 함께 응답한다.
AccessToken 의 경우에는 서버에서 TTL 으로 토큰을 가지고 있어야 하지만, ID Token 의 경우 payload 에 TTL 이 있기 때문에 서버에서 가지고 있지 않아도 된다.
// auth-server.js 내 수정
app.post('/oauth/token', (req, res) => {
const { client_id, client_secret, code } = req.body;
// 클라이언트 인증 및 코드 유효성 검사
if (client_id !== REGISTERED_CLIENT.id || client_secret !== REGISTERED_CLIENT.secret) {
return res.status(401).json({ error: 'invalid_client' });
}
const codeData = authCodes.get(code);
if (!codeData || codeData.expiresAt < Date.now()) {
return res.status(400).json({ error: 'invalid_grant', message: '유효하지 않거나 만료된 코드입니다.' });
}
// 검증이 끝난 인가 코드는 폐기 (보안상 1회용)
authCodes.delete(code);
// Access Token 생성
const accessToken = 'access_token_' + Math.random().toString(36).substring(7);
accessTokens.set(accessToken, { user: codeData.user });
// ID Token 생성
const idToken = createIDToken(codeData.user, client_id);
// 토큰 발급
res.json({
access_token: accessToken,
id_token: idToken, // 신원 정보가 담긴 JWT 추가
token_type: 'Bearer',
expires_in: 3600
});
});
4. 클라이언트 구현: ID Token 검증 및 읽기
클라이언트(client-server.js)는 받은 id_token을 디코딩하여 사용자가 누구인지 확인할 수 있다.
JWT 디코딩 (서버 단)
// client-server.js 내 콜백 처리 부분
app.get('/callback', async (req, res) => {
const code = req.query.code;
if (!code) {
return res.status(400).send('Authorization Code가 없습니다.');
}
try {
const tokenResponse = await fetch(`${AUTH_SERVER}/oauth/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
code: code
})
});
const { access_token, id_token } = await tokenResponse.json();
// JWT의 페이로드 부분(두 번째 섹션) 추출, 디코딩
const payloadPart = id_token.split('.')[1];
const decodedPayload = JSON.parse(
Buffer.from(payloadPart, 'base64').toString('utf-8')
);
console.log('인증된 사용자 정보:', decodedPayload);
res.send(`
<h1>OIDC 로그인 성공</h1>
<p>환영합니다, <strong>${decodedPayload.name}</strong>님!</p>
<p>이메일: ${decodedPayload.email}</p>
<p>발급자: ${decodedPayload.iss}</p>
<hr>
<p style="word-break: break-all;">전달받은 ID Token(JWT): <br>${id_token}</p>
`);
} catch (error) {
console.error('OAuth 처리 중 에러 발생:', error);
res.status(500).send('서버 내부 에러');
}
});
OIDC 를 써야하는 이유
- 왕복(Round-trip) 감소: OAuth 2.0만 쓸 때는 액세스 토큰을 받은 뒤 유저 정보를 가져오기 위해 리소스 서버에 다시 요청해야 했다. 하지만 OIDC는 토큰 자체에 유저 정보가 들어있어 서버 통신 횟수를 줄일 수 있다.
- 표준화된 Claim: 유저 ID는
sub, 이메일은email등 필드명이 표준화되어 있어 어떤 서비스와 연동하든 로직이 일관된다. - Stateless 인증: 세션 데이터베이스를 조회하지 않아도 토큰 내부의 서명만 검증하면 신뢰할 수 있는 데이터임을 알 수 있다.
마무리하며
OIDC가 어떻게 OAuth 2.0을 확장하는지, 그리고 JWT가 어떻게 구성되고 서명되는지 라이브러리 없이 직접 코드로 구현했다.
실무에서는 안전을 위해 jsonwebtoken이나 openid-client 같은 검증된 라이브러리를 사용해야 하지만, 이렇게 직접 알고리즘에 맞춰 서명을 만들고 Base64Url을 다뤄보는 경험은 트러블슈팅을 할 때 의외로 도움이 된다.
'임시보관 > 로그인 서비스' 카테고리의 다른 글
| PassKey (0) | 2024.02.26 |
|---|---|
| FIDO (0) | 2024.02.26 |
| [인증]: RFC 6749 - The OAuth 2.0 Authorization Framework (0) | 2024.02.26 |