Blog

OIDC 1: OAuth 2.0과 ID Token, 공개키 검증 이해하기

OAuth 2.0에 인증을 더한 OIDC

OIDC(OpenID Connect)는 OAuth 2.0 위에 인증 계층을 더한 프로토콜입니다. 차이를 이해하려고 먼저 OAuth 2.0의 범위를 살펴봤습니다.

OAuth 2.0 표준 RFC 6749는 외부 애플리케이션이 권한을 얻는 절차를 정의합니다. 애플리케이션은 이 권한으로 리소스에 제한적으로 접근합니다. 사용자 정보를 어떤 형식의 API로 가져올지는 이 표준에 포함되어 있지 않습니다. 서비스마다 제공하던 사용자 정보 API는 OAuth 2.0 자체의 공통 규격과는 별개입니다.

그렇다면 OIDC에서는 무엇이 달라질까요? ID Token이 추가됩니다. 이 토큰으로 사용자가 누구인지 확인합니다. 리소스 접근에는 Access Token을, 사용자 인증 결과 확인에는 ID Token을 사용합니다.

  • 인증(Authentication)은 사용자의 신원을 확인하는 일입니다.
  • 인가(Authorization)는 리소스에 접근할 권한을 부여하고 확인하는 일입니다.

캘린더 서비스를 예로 들면

OAuth 2.0으로 캘린더 접근 권한을 받아 Access Token을 발급받았다고 가정합니다. 이 토큰으로 캘린더 API를 호출합니다. 다만 클라이언트가 사용자의 신원을 확인하는 공통 절차는 OAuth 2.0 표준에 정해져 있지 않습니다. 사용자 식별이 필요하면 제공자의 사용자 정보 API 등을 별도로 사용하곤 합니다.

OIDC의 Authorization Code 흐름에서는 토큰 응답으로 Access Token과 ID Token을 받을 수 있습니다. ID Token을 검증하고 나면 그 안의 사용자 식별 정보를 사용할 수 있습니다. 캘린더 데이터를 요청할 때 Access Token을 쓰는 점은 같습니다.

ID Token의 사용자 식별과 UserInfo 호출은 구분해서 봐야 합니다. 추가 프로필 정보가 필요하면 OIDC에서도 UserInfo API를 사용할 수 있습니다. 이 API는 OIDC Core의 UserInfo Request에 표준으로 정의되어 있습니다.

ID Token에 담긴 값

OIDC Core의 ID Token 정의에 실린 예시입니다.

JSON
{
   "iss": "https://server.example.com",
   "sub": "24400320",
   "aud": "s6BhdRkqt3",
   "nonce": "n-0S6_WzA2Mj",
   "exp": 1311281970,
   "iat": 1311280970,
   "auth_time": 1311280969,
   "acr": "urn:mace:incommon:iap:silver"
  }

토큰 안의 각 항목을 클레임이라고 부릅니다.

클레임필수 여부의미
iss필수토큰 발급자의 식별자
sub필수해당 발급자 안에서의 사용자 식별자
aud필수토큰의 대상 클라이언트, 일반적으로 client_id
exp필수토큰 만료 시각, Unix 시간의 초 단위
iat필수토큰 발급 시각, Unix 시간의 초 단위
auth_time조건부실제 사용자 인증 시각
nonce조건부요청과 응답을 연결하고 재생 공격을 방지하는 값
acr선택인증 컨텍스트 클래스 참조
amr선택비밀번호나 OTP 등 사용한 인증 방식
azp선택토큰을 발급받도록 허가된 대상

auth_time과 nonce는 요청 조건에 따라 요구됩니다. 선택 항목이라는 말만 보고 필요한 검증까지 생략하면 안 됩니다.

사용자 식별자는 sub만 전역 식별자로 취급하지 않고 발급자 iss와 함께 봐야 합니다.

카카오의 ID Token 예시

당시 카카오 개발자 문서에서 확인한 payload입니다.

JSON
{
    "iss": "https://kauth.kakao.com",
    "aud": "${APP_KEY}",
    "sub": "166959",
    "iat": 1647183250,
    "exp": 1647190450,
    "nonce": "${NONCE}",
    "auth_time": 1647183250
}

iss, sub, aud, exp, iat 다섯 필수 클레임과 nonce, auth_time이 들어 있습니다. 제공자의 정책과 동의 범위에 따라 이메일이나 이름 같은 정보가 추가될 수 있습니다.

내용을 읽는 것과 검증은 다릅니다

ID Token은 JWT 형식입니다. 사용 전에는 ID Token Validation에 따른 검증이 필요합니다. payload를 디코딩해 읽을 수 있다고 해서 그 내용을 신뢰할 수 있는 것은 아니기 때문입니다.

카카오처럼 RSA 서명을 사용하는 경우 제공자의 공개키로 서명을 확인할 수 있습니다. 발급자와 대상 클라이언트, 만료 시각, 요청에서 사용한 nonce 등도 검증해야 합니다. 서명이 맞는다는 사실 하나만으로 우리 서비스가 받아야 할 토큰임이 보장되지는 않습니다.

당시에는 ID Token을 모두 비대칭키로 서명한다고 이해했습니다. 하지만 OIDC는 대칭키 기반 서명 방식도 정의합니다. 이 글의 코드는 RSA 공개키를 사용하는 경우에 한정됩니다. 전체 규격은 OIDC Core를 참고할 수 있습니다.

공개키 정보는 JWK(JSON Web Key) 형식으로 표현할 수 있습니다. OIDC Discovery 문서인 /.well-known/openid-configuration에서 jwks_uri를 확인하고 그 주소에서 키 집합을 가져오는 구조입니다.

카카오의 공개키 조회 문서에 나온 주소는 https://kauth.kakao.com/.well-known/jwks.json입니다. 아래는 글을 쓸 때 가져온 키 예시입니다. 그래서 지금도 같은 키라고 가정해서는 안 됩니다. 키 이름에 따옴표가 빠져 있어 엄밀한 JSON 문법은 아닙니다.

{
    keys: [
        {
            kid: "3f96980381e451efad0d2ddd30e3d3",
            kty: "RSA",
            alg: "RS256",
            use: "sig",
            n: "q8zZ0b_MNaLd6Ny8wd4cjFomilLfFIZcmhNSc1ttx_oQdJJZt5CDHB8WWwPGBUDUyY8AmfglS9Y1qA0_fxxs-ZUWdt45jSbUxghKNYgEwSutfM5sROh3srm5TiLW4YfOvKytGW1r9TQEdLe98ork8-rNRYPybRI3SKoqpci1m1QOcvUg4xEYRvbZIWku24DNMSeheytKUz6Ni4kKOVkzfGN11rUj1IrlRR-LNA9V9ZYmeoywy3k066rD5TaZHor5bM5gIzt1B4FmUuFITpXKGQZS5Hn_Ck8Bgc8kLWGAU8TzmOzLeROosqKE0eZJ4ESLMImTb2XSEZuN1wFyL0VtJw",
            e: "AQAB"
        },
        {
            kid: "9f252dadd5f233f93d2fa528d12fea",
            kty: "RSA",
            alg: "RS256",
            use: "sig",
            n: "qGWf6RVzV2pM8YqJ6by5exoixIlTvdXDfYj2v7E6xkoYmesAjp_1IYL7rzhpUYqIkWX0P4wOwAsg-Ud8PcMHggfwUNPOcqgSk1hAIHr63zSlG8xatQb17q9LrWny2HWkUVEU30PxxHsLcuzmfhbRx8kOrNfJEirIuqSyWF_OBHeEgBgYjydd_c8vPo7IiH-pijZn4ZouPsEg7wtdIX3-0ZcXXDbFkaDaqClfqmVCLNBhg3DKYDQOoyWXrpFKUXUFuk2FTCqWaQJ0GniO4p_ppkYIf4zhlwUYfXZEhm8cBo6H2EgukntDbTgnoha8kNunTPekxWTDhE5wGAt6YpT4Yw",
            e: "AQAB"
        }
    ]
}
필드의미
kid키 식별자
ktyRSA, EC 같은 키 종류
algRS256 같은 알고리즘
usesig는 서명용, enc는 암호화용
nRSA modulus를 Base64url로 인코딩한 값
eRSA 공개 지수를 Base64url로 인코딩한 값

n과 e로 RSA 공개키를 만들었습니다

이전 사이드 프로젝트에서 사용했던 공개키 생성 메서드입니다. 필요한 키는 이미 선택했다고 보고 그 뒤의 변환 부분만 코드로 남겼습니다. 전체 검증 구현은 이 코드에 들어 있지 않습니다.

당시에는 “키를 바로 알려 주지 않는다”고 표현했습니다. 실제로는 JWK의 n과 e 자체가 RSA 공개키를 표현하는 값입니다. 아래 코드는 이 값을 Java에서 사용할 객체 형태로 변환합니다. 파라미터 정의는 RFC 7518의 RSA 키 파라미터에 있습니다.

Java
import java.math.BigInteger;
import java.security.KeyFactory;
import java.security.NoSuchAlgorithmException;
import java.security.PublicKey;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.RSAPublicKeySpec;

private PublicKey getPublicKey(Map<String, Object> findKey) throws InvalidKeySpecException, NoSuchAlgorithmException {
        final byte[] decodeN = Base64.getUrlDecoder().decode((String) findKey.get("n"));
        final byte[] decodeE = Base64.getUrlDecoder().decode((String) findKey.get("e"));

        final BigInteger n = new BigInteger(1, decodeN);
        final BigInteger e = new BigInteger(1, decodeE);

        final RSAPublicKeySpec keySpec = new RSAPublicKeySpec(n, e);

        return KeyFactory.getInstance("RSA").generatePublic(keySpec);
}

n과 e는 정수를 바이트 배열로 표현한 뒤 Base64url로 인코딩한 값입니다. 이를 디코딩하고 BigInteger로 변환한 다음 RSAPublicKeySpec에 전달합니다. new BigInteger(1, ...)의 첫 인자는 양수로 해석하도록 지정합니다.

이렇게 만든 공개키는 서명 검증에 사용할 재료입니다. 위 메서드는 공개키만 만들고 토큰 검증이나 사용자 인증은 하지 않습니다.

당시에는 공개키를 만드는 부분까지 정리했습니다. Java로 구현한 전체 OIDC 흐름과 디자인 패턴은 다음 글에서 다뤄 보려고 했습니다.