흔히 마주칠 수 있는 소셜 로그인, 그 이면에 숨겨진 보안 메커니즘에 대해 생각해보기
최근 프로젝트에서 페이스북 소셜 로그인 기능을 구현하는 업무를 맡게 되었습니다. 기존에는 구글과 애플 로그인이 이미 구현되어 있었고, 서버에서 nonce
와 state
값을 발급받아 사용하는 하이브리드 플로우 방식을 사용하고 있었습니다. 그래서 저는 페이스북 역시 동일한 방식으로 구현할 수 있을 것이라 예상했죠.
하지만 페이스북 공식 문서를 확인해보니, 예상과 달리 클라이언트에서 추가적인 보안 조치를 적용해야만 액세스 토큰을 얻을 수 있다는 점을 알게 되었습니다. 바로 PKCE가 적용된 OIDC 인증 방식이 필요하다는 것이었습니다.
물론 클라이언트에서 추가 인증 과정을 생략하고 액세스 토큰을 받아오는 방법도 있었지만, 이는 보안상 취약점이 있어 권장되지 않는 방식이었습니다. 그래서 가능하다면 PKCE가 적용된 OIDC 인증 방식을 써야 한다는 점을 알게 되었죠.
사실 OAuth의 전체적인 흐름에 대해 잘 알지 못했던 터라, 낯선 용어들을 접하니 막막함이 느껴지더라구요. 그렇지만 회사 업무인 만큼 팀 동료들에게도 이 개념을 잘 설명할 수 있어야 했기에 학습에 대한 동기부여로도 이어졌습니다. 그래서 본격적인 기능 구현에 앞서 열심히 자료를 찾아보며 해당 개념을 이해하려고 노력했고, 결과적으로 코드 리뷰도 잘 마치고 기능 구현까지 성공적으로 완료할 수 있었습니다.
그래서 오늘은 제가 직접 경험한 페이스북 로그인 구현 사례를 바탕으로, OIDC와 PKCE를 활용해 클라이언트 측에서 어떻게 안전하게 사용자 인증을 구현할 수 있는지 알아보고자 합니다. OIDC와 PKCE의 개념을 이해하고 실제 적용 방법이 궁금하신 분들께 도움이 되기를 바랍니다.
개념 정리
PKCE가 적용된 OIDC 방식의 소셜 로그인을 다루는 만큼, 필수적으로 등장하는 개념인 OAuth 2.0, OIDC (OpenID Connect), 그리고 PKCE (Proof Key for Code Exchange) 에 대해 간단히 정리해보겠습니다.
OAuth 2.0
OAuth 2.0 시퀀스 다이어그램, 출처는 여기
OAuth 2.0 은 권한 부여(Authorization)에 사용되는 프로토콜 로, 클라이언트 (애플리케이션)가 사용자를 대신하여, 또는 애플리케이션 자체가 특정 자원에 대해 제한된 접근 권한을 얻을 수 있도록 하는 역할을 합니다.
구글 드라이브에 접근 권한을 요구하는 서비스를 이용하는 시나리오를 한 번 떠올려봅시다.
- 철수는 SNS 웹 사이트의 구글 로그인 버튼을 클릭합니다.
- 웹 페이지가 구글의 권한 허용 페이지로 리다이렉트되며, 이 액세스에 대한 동의를 제공하라는 메시지가 표시됩니다.
- 철수는 동의 버튼을 클릭합니다.
- 다시 SNS로 리다이렉트가 되는데, 이때 SNS는 구글로부터 권한을 부여받는 과정을 거칩니다.
- SNS는 구글로부터 액세스 토큰을 받아, 철수가 구글 드라이브에 올린 사진첩에 접근할 수 있게 됩니다.
우리는 로그인 버튼을 누를 때, 내 구글 계정의 ID와 비밀번호를 해당 서비스에 직접 입력하는 것이 아니라, 구글의 권한 동의 페이지로 리다이렉트되는 경험을 해보셨을 겁니다.
여기에서 동의 버튼을 누르게 되면 해당 서비스는 구글로부터 해당 권한을 실행할 수 있는 액세스 토큰(Access Token) 을 받게 되고, 그 토큰을 이용해서 서비스는 비로소 내가 가진 자원에 접근할 수 있게 되죠.
이처럼 사용자는 자신의 구글 ID와 비밀번호를 해당 사이트에 직접 제공하지 않고, 구글을 통해 안전하게 자원에 접근할 권한을 위임합니다. 이 흐름이 바로 OAuth 2.0의 핵심입니다.
이 과정에는 네 가지 핵심 개념이 등장합니다.
- 자원 소유자(Resource Owner): 사용자 본인
- 클라이언트(Client): 우리가 개발하는 애플리케이션 (구글 로그인을 연동하려는 서비스)
- 권한 부여 서버(Authorization Server): 사용자를 인증하고 클라이언트에게 액세스 토큰(Access Token)을 발급해주는 서버,
- 자원 서버(Resource Server): 사용자의 정보(자원)를 실제로 보유하고 있는 서버 (예: Facebook, Google)
이러한 OAuth 2.0에는 액세스 토큰을 발급받기 위한 인증 흐름의 종류를 정의하는 권한 부여 유형(Grant Type), 그 중에서도 권한 부여 코드 플로우(Authorization Code Flow) 라는 녀석이 오늘 이야기할 PKCE와 관련이 깊으니 기억해둘 필요가 있습니다.
클라이언트가 요청하는 권한의 범위를 나타내는 스코프(Scope) 처럼 추가적인 개념도 있는데요, 조금 더 구체적인 내용이 궁금하다면 이 문서를 참고해보세요.
여기서 중요한 점은 OAuth 2.0은 인증(Authentication) 이 아니라 권한 부여(Authorization) 를 위한 프로토콜이라는 점입니다. 즉, OAuth 2.0은 사용자가 누구인지에 대한 정보를 확인하지 않습니다.
OIDC
OIDC 시퀀스 다이어그램, 출처는 여기
이러한 이유로 등장한 OIDC(OpenID Connect) 는 OAuth 2.0 위에 구축된 프로토콜로, 사용자 인증을 위한 표준화된 방법을 제공합니다. OIDC는 사용자 인증과 ID 정보를 액세스 토큰과 함께 ID 토큰(ID Token) 형태로 제공합니다.
그런데 OIDC는 왜 필요한 걸까요? 단순히 OAuth 2.0으로 발급받은 토큰으로 로그인한 계정의 정보를 API로 조회하면 충분하지 않을까요?
이는 OAuth 2.0의 근본적인 목적과 관련이 있습니다. OAuth 2.0의 액세스 토큰은 특정 자원에 접근할 수 있는 권한만을 증명할 뿐, 그 토큰을 사용하는 사람이 정말 토큰의 주인인지는 보장하지 않습니다. 만약 공격자가 액세스 토큰을 탈취한다면, 다른 사람인 척하며 자원에 접근할 수도 있겠죠. 이처럼 액세스 토큰과 프로필 API의 조합만으로는 인증된 사용자가 접근한다는 사실을 온전히 보장하기 어렵습니다.
즉, OAuth 2.0의 액세스 토큰과 프로필 API는 사용자의 리소스 접근 만 보장할 뿐, 사용자가 인증되었고 누구인지 는 보장하지 않습니다. 그래서 ID 토큰이라는 추가적인 인증 정보가 필요하며, 이 정보를 ID 토큰 형태로 제공하는 것이 OIDC의 핵심입니다.
ID 토큰은 JWT 형식으로 만들어집니다. 덕분에 클라이언트 애플리케이션은 구글, 페이스북 등 여러 제공자를 OIDC로 연동하더라도, 각각의 사용자 정보를 일관된 방식으로 안전하게 처리할 수 있다는 장점이 있습니다.
PKCE
PKCE의 표준을 정의한 RFC 7636
PKCE(Proof Key for Code Exchange) 는 OAuth 2.0의 권한 부여 유형 중 권한 부여 코드 플로우(Authorization Code Flow)에 추가되는 보안 강화 확장 으로, “픽시” 라고 발음합니다.
권한 부여 코드 플로우는 서버로부터 액세스 토큰을 바로 전달받는 것이 아니라, 토큰을 교환하기 위한 권한 코드(Authorization Code) 를 먼저 발급받고, 이 코드를 사용하여 액세스 토큰을 요청하는 방식입니다. 비유하자면 액세스 토큰 교환권 을 발급해서 전달하는 것과 같죠. 이를 통해 액세스 토큰이 URL에 직접 노출되지 않도록 할 수 있다는 것이 장점입니다.
그런데 문제는 이 권한 코드도 URL을 통해 전달되기 때문에, 권한 코드 자체가 탈취될 경우 해당 코드로 액세스 토큰을 발급받을 수 있다는 점입니다. 액세스 토큰이 쉽게 탈취되지 않도록 액세스 토큰 교환권 을 발급했는데, 이 교환권이 탈취당한다면 결국 액세스 토큰을 탈취당하는 것과 마찬가지라 조삼모사와 같은 상황이 되는 셈이죠.
만약 별도의 서버를 구축해 클라이언트별 고유값인 시크릿(Client Secret)을 안전하게 숨길 수 있다면 문제가 없지만, 모바일 앱이나 브라우저에서 실행되는 SPA(Single Page Application) 등에서는 이 시크릿이 쉽게 노출될 수 있다는 문제가 있습니다.
좀 더 구체적인 시나리오를 살펴보자면…
- 악성 브라우저 확장 프로그램: 사용자가 설치한 브라우저 확장 프로그램은 웹 페이지의 내용을 읽거나 수정할 권한을 가지므로 악의적으로 제작된 확장 프로그램은 페이지가 리디렉션될 때 URL에서 권한 코드를 쉽게 빼낼 수 있음
- 프록시 서버 또는 악성 Wi-Fi: 사용자가 신뢰할 수 없는 프록시 서버나 공용 Wi-Fi를 사용할 경우, 공격자는 사용자의 모든 웹 트래픽을 들여다볼 수 있으므로 권한 코드가 탈취될 수 있음
- 서버 로그: 웹 서버(Nginx 등)나 클라우드 서비스(Cloudflare Workers 등)는 일반적으로 모든 요청 URL을 로그로 기록하므로, 로그 파일 접근 권한이 공격자에게 넘어가면 권한 코드 또한 탈취될 수 있음
- Referer 헤더 유출: 사용자가 권한 코드가 포함된 URL로 리디렉션된 후, 해당 페이지에 있는 링크를 클릭하여 다른 사이트로 이동하면 HTTP의 Referer 헤더에 이전 페이지의 URL(권한 코드 포함)이 담겨 넘어갈 수 있음
- XSS 공격: 앱에 XSS 취약점이 존재하는 경우, 악성 스크립트가 클라이언트의 권한 코드를 탈취할 수 있음
- Open Redirect 취약점: 공격자가
redirect_uri
가 악성 URI로 변조된 링크를 클릭하도록 유도하면 공격자가 권한 코드를 탈취할 수 있음
결국 모든 문제의 원인은 바로 권한 코드가 클라이언트의 URL에 노출되어 탈취당할 위험이 있기 때문입니다. 그렇기 때문에 PKCE에서는 액세스 토큰은 URL 로 전달되는 게 아니라, 주로 별도로 마련된 토큰 조회 API 를 통해서 발급됩니다. HTTPS 프로토콜을 사용해 통신하기 때문에 이 토큰은 TLS 암호화로 보호되죠.
따라서 토큰을 제대로 발급받기 위해서는, 인증을 요청한 클라이언트가 토큰을 요청한 클라이언트와 동일한 주체임을 인증 서버에 증명해야 합니다. ‘인증을 요청한 것도 나고, 이 코드를 전달받은 것도 나니까 진짜 액세스 토큰을 내놔!’ 라고 요청하는 것이죠.
PKCE의 시퀀스 다이어그램, 출처는 여기
이를 위해서 PKCE는 보증 키(Proof Key) 라는 개념을 사용합니다. 이를 통해 클라이언트가 권한 코드를 요청할 때, 해당 코드가 실제로 이 클라이언트에 의해 요청되었음을 증명하죠. 이 과정은 다음과 같이 진행됩니다.
- 클라이언트가 증명을 위한 임의의 비밀 문자열
code_verifier
을 생성code_verifier
를 특정 방식(주로 SHA256)으로 해싱하여code_challenge
를 생성- 클라이언트는 로그인 요청 시
code_challenge
를 권한 부여 서버(예: Facebook)로 전송- 권한 부여 서버는 이
code_challenge
를 저장한 뒤, 권한 코드를 클라이언트에게 발급- 클라이언트는 발급받은 권한 코드와 함께, 원본 비밀 문자열인
code_verifier
를 권한 부여 서버에 보내 액세스 토큰을 요청- 권한 부여 서버는 전달받은
code_verifier
를 저장해둔code_challenge
와 비교/검증하여 일치할 경우에만 액세스 토큰을 최종 발급
code_verifier
와 code_challenge
라는 용어가 갑자기 등장해서 낯설게 느껴지실 수도 있는데요. 핵심은 code_verifier
라는 임의의 변수를 해싱(hashing)해서 나오는 값이 code_challenge
라는 내용입니다.
해싱의 특성상 code_verifier
를 알면 code_challenge
를 만들 수 있지만, 반대로 code_challenge
만으로는 code_verifier
를 유추할 수 없다는 점이 보안의 핵심이죠.
이 과정을 통해 인증 서버는 클라이언트가 실제로 권한 코드를 요청한 주체임을 검증할 수 있습니다. 만약 권한 코드가 탈취되더라도, 원본 비밀값인 code_verifier
를 알지 못하면 토큰을 발급받을 수 없습니다. PKCE는 이처럼 권한 코드 플로우의 보안을 크게 강화합니다.
이러한 흐름을 표준으로 정의한 것이 바로 RFC 7636입니다.
Facebook 로그인 예제
이야기가 길어졌네요. 이제 본격적으로 페이스북 로그인에서 요구하는 PKCE가 적용된 OIDC 코드 플로우를 구현하는 예제를 살펴보겠습니다.
페이스북 앱 생성
페이스북 로그인을 구현하기 위해서는 먼저 페이스북 개발자 센터에서 앱을 생성해야 합니다. 아래 단계를 따라 앱을 생성합니다.
우선, 페이스북 개발자 센터에 접속하여 우측 상단의 ‘앱 만들기’ 버튼을 클릭합니다.
두 번째 단계로 넘어가면 ‘이용 사례’ 를 추가할 수 있는데요. 여기에서 ‘Facebook 로그인을 통한 인증 및 사용자의 데이터 요청’ 항목을 선택한 후 나머지 단계를 거쳐 앱 생성을 완료합니다. 앱 생성을 완료하면 앱 목록에서 앱 ID를 확인할 수 있습니다.
앱 생성이 완료되었다면, ‘이용 사례’ 메뉴에 들어가 Facebook 로그인 설정을 위해 ‘맞춤’ 을 선택합니다.
‘클라이언트 OAuth 설정 항목’ 에 위치한 ‘유효한 OAuth 리디렉션 URI’ 를 입력하는 칸으로 이동합니다.
이곳에는 소셜 로그인 후 페이스북이 사용자를 다시 돌려보낼 URL을 입력해야 합니다. localhost
도 가능하지만, HTTPS 프로토콜 적용을 권장합니다. 저는 https://localhost:3000/callback
을 입력했습니다.
클라이언트 앱 생성
이와 더불어 클라이언트 앱 생성도 해야 하는데요, 저는 create-next-app 을 사용하여 Next.js 앱을 생성하였습니다. 그리고 로컬 서버 실행 시 간단하게 HTTPS 환경을 적용하도록 next dev --experimental-https
명령어를 사용했습니다.
생성한 페이지는 총 2개로, 루트 경로(/
)에는 페이스북 로그인을 시작하는 버튼을 배치하고, 리다이렉션 경로(/callback
)에는 페이스북 인증 후 리다이렉트되는 페이지를 구현하려고 합니다.
이제 페이스북 로그인 버튼을 눌렀을 때 실행되는 로직을 구현하겠습니다.
code_verifier
생성
가장 먼저, 클라이언트에서 암호화된 문자열 쌍을 생성해야 합니다. 이 과정은 서버와의 통신 없이 클라이언트에서 자체적으로 수행됩니다. 저희는 일단 웹 애플리케이션을 기준으로 설명하려고 하니, TypeScript를 사용하여 생성해보겠습니다.
Math.random()
는 시드 기반으로 유사 난수 를 제공하기 때문에 보안을 위한 난수 생성 용도로는 별도의 API 사용을 권장하고 있다
PKCE 표준에 따르면 code_verifier
는 임의의 문자열이어야 하는데, 이 문자열은 암호학적으로 안전한 무작위 문자열이어야 합니다. 여기서 암호학적으로 안전한 난수란, 무작위성(엔트로피)이 충분히 높아 예측이 불가능한 난수를 의미합니다.
Math.random()
은 시드 기반으로 유사 난수를 생성하기 때문에, 예측 가능한 패턴이 있어 보안 용도로는 적합하지 않습니다.
window.crypto.getRandomValues()
로 안전한 난수를 생성하자
이를 대체하는 함수로는 JavaScript의 window.crypto.getRandomValues()
가 있는데요, 이 메서드를 사용하면 무작위성이 충분히 큰 난수를 생성할 수 있습니다.
code_verifier
는 43자에서 128자 사이의 길이를 가지며, Base64URL 인코딩 을 사용하여 표현합니다. Base64URL은 URL에서 안전하게 사용할 수 있도록 일부 문자를 대체한 Base64 인코딩 방식으로, 일반적인 Base64 인코딩과 유사하지만 +
, /
문자를 각각 -
, _
로 대체하고 마지막 패딩 문자 =
를 제거한다는 특징이 있습니다.
이러한 제약 조건을 만족하는 code_verifier
를 생성하는 TypeScript 코드는 다음과 같습니다.
const getBase64UrlEncoded = (bytes: Uint8Array): string =>
window
.btoa(
Array.from(bytes)
.map((byte) => String.fromCharCode(byte))
.join('')
)
.replaceAll('+', '-')
.replaceAll('/', '_')
.replaceAll('=', '');
const generateCodeVerifier = (length = 32): string => {
const bytes = new Uint8Array(length);
window.crypto.getRandomValues(bytes);
const codeVerifier = getBase64UrlEncoded(bytes);
return codeVerifier;
};
code_challenge
생성
code_verifier
를 생성했다면 이를 기반으로 code_challenge
를 생성해야 합니다. code_challenge
를 생성하는 방법은 크게 두 가지가 있습니다.
- Plain:
code_verifier
를 그대로 사용합니다.- S256:
code_verifier
를 SHA-256 해시한 후, Base64URL 인코딩을 적용합니다.
1번의 경우에는 PKCE 적용을 하는 의미가 사실상 없기 때문에, 2번의 방법을 사용하여 code_challenge
를 생성하는 것이 일반적입니다. 따라서 이번 예제에서도 S256
방식을 사용하여 code_challenge
를 생성하겠습니다.
JavaScript에서 SHA-256 해시를 적용할 수 있는 네이티브 메서드
JavaScript 에서는 crypto.subtle.digest()
메서드를 사용하여 SHA-256 해시를 생성할 수 있습니다.
이 메서드는 비동기적으로 동작하므로, async
와 await
구문을 사용하여 처리해야 하며, HTTPS 환경에서만 사용할 수 있습니다.
이렇게 나온 해시를 다시 Base64URL 인코딩하면 code_challenge
가 생성됩니다. 이를 TypeScript 로 구현한 코드는 다음과 같습니다.
const generateCodeChallenge = async (codeVerifier: string): Promise<string> => {
const encoder = new TextEncoder();
const encodedCodeVerifier = encoder.encode(codeVerifier);
const hashedCodeVerifier = await window.crypto.subtle.digest(
'SHA-256',
encodedCodeVerifier
);
const bytes = new Uint8Array(hashedCodeVerifier);
const codeChallenge = getBase64UrlEncoded(bytes);
return codeChallenge;
};
code_verifier
를 저장
PKCE 흐름에서는 클라이언트로 리다이렉트가 된 후, 최종적으로 액세스 토큰을 요청할 때 code_verifier
를 사용해야 합니다. 따라서 이 값을 리다이렉트 페이지에서도 접근할 수 있게 저장해두는 것이 중요합니다. 일반적으로는 로컬 스토리지나 세션 스토리지에 저장할 수 있습니다.
localStorage.setItem('code_verifier', codeVerifier);
인증 서버에 요청 전송
이제 생성된 code_challenge
를 포함하여 사용자를 페이스북 인증 페이지로 보냅니다. 공식 문서에서 제공하고 있는 파라미터를 참고하여 아래와 같은 URL을 구성합니다.
const url =
`https://www.facebook.com/v11.0/dialog/oauth?` +
`client_id=${FACEBOOK_APP_ID}` + // 필수값, 페이스북 개발자 센터에서 발급받은 앱 ID
`&scope=openid` + // 필수값, OIDC with PKCE 에서는 반드시 openid로 설정 필요
`&response_type=code` + // 필수값, OIDC with PKCE 에서는 위해서 반드시 code로 설정 필요
`&redirect_uri=${FACEBOOK_REDIRECT_URI}` + // 필수값, 페이스북 앱 생성시 설정한 URI
`&code_challenge=${codeChallenge}` + // 필수값, 위의 코드로 생성한 code_challenge 값
`&code_challenge_method=S256` + // 권장값, code_challenge의 해싱 방법으로 여기서는 S256을 사용, 기본값은 plain
`&state=${state}` + // 권장값, CSRF 공격을 방지하기 위한 임의의 문자열
`&nonce=${nonce}`; // 필수값, OIDC에서 ID 토큰의 재생 공격을 방지하기 위한 임의의 문자열
router.push(url);
nonce
는 ID 토큰의 위조 및 재사용을 방지하기 위해,state
는 인증 요청에 대한 응답의 무결성과 CSRF 공격을 방지하기 위해 클라이언트가 검증해야 하는 값입니다. OAuth를 제공하는 사이트마다 요구하는 파라미터의 조건이 다를 수 있다는 점 참고해주세요.
이 URL을 통해 사용자는 페이스북의 로그인 페이지로 리다이렉트됩니다. 그러면 버튼 클릭 시 위와 같이 페이스북의 인증 페이지로 리다이렉트가 됩니다. 사용자는 페이스북 계정으로 로그인하고, 애플리케이션에 권한을 부여하는 과정을 거치게 되죠.
페이스북 로그인 버튼 클릭 시 인증 요청을 보내는 모습
최종적으로 페이스북 로그인 버튼 클릭 시 실행할 함수는 다음과 같이 작성할 수 있습니다.
const handleFacebookLogin = async () => {
const codeVerifier = generateCodeVerifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);
const state = generateRandomString(); // CSRF 공격 방지를 위한 임의의 문자열 생성
const nonce = generateRandomString(); // ID 토큰의 재생 공격 방지를 위한 임의의 문자열 생성
localStorage.setItem('code_verifier', codeVerifier); // code_verifier를 로컬 스토리지에 저장
localStorage.setItem('state', state); // state를 로컬 스토리지에 저장
const url = `...`; // 위에서 작성한 URL
router.push(url);
};
리다이렉트 URI에서 액세스 토큰 획득
사용자가 성공적으로 인증을 마치면, 페이스북은 2단계에서 지정한 redirect_uri
로 사용자를 돌려보냅니다. 이때 URL의 쿼리 파라미터로 code
와 state
가 포함됩니다. code
에는 ID 토큰을 요청할 수 있는 권한 코드가 담겨 있고, state
는 CSRF 공격을 방지하기 위해 사용됩니다.
https://localhost:3000/callback?code={code}&state={state}
여기서 클라이언트는 CSRF 공격을 방지하기 위해, 2단계에서 생성한 state
값을 먼저 검증해야 합니다. 요청에 보낸 state
값과 콜백 URI에 포함된 state
값을 비교하여 일치하는지 확인합니다.
// callback.tsx
const state = localStorage.getItem('state');
if (state !== router.query.state) {
throw new Error('Invalid state parameter'); // CSRF 공격 방지를 위한 검증
}
그 후, 획득한 code
와 함께 숨겨두었던 code_verifier
를 사용하여 최종적으로 액세스 토큰을 교환하는 API 를 호출합니다.
페이스북 서버는 전달받은 code_verifier
를 해싱하여 저장해두었던 code_challenge
와 비교합니다. 값이 일치하면, 인증된 클라이언트라고 판단하고 액세스 토큰과 ID 토큰 등이 포함된 JSON 응답을 반환합니다.
// callback.tsx
const codeVerifier = localStorage.getItem('code_verifier');
const code = router.query.code;
const url =
`https://graph.facebook.com/v11.0/oauth/access_token?` +
`client_id=${FACEBOOK_APP_ID}` + // 페이스북 개발자 센터에서 발급받은 앱 ID
`&redirect_uri=${FACEBOOK_REDIRECT_URI}` + // 페이스북 앱 생성 시 설정한 URI
`&code_verifier=${codeVerifier}` + // 페이스북 로그인 요청 시 생성한 code_verifier
`&code=${code}`; // 페이스북 로그인 후 리다이렉트된 URI에서 추출한 code
const response = await fetch(url).then((res) => res.json());
console.log(response);
// {
// "access_token": string // 페이스북 API에 접근할 수 있는 액세스 토큰
// "expires_in": number // 토큰의 유효 기간 (초 단위)
// "id_token": string // JWT 형식의 ID 토큰, 사용자의 인증 정보를 포함
// "token_type": "bearer" // 토큰의 유형, 일반적으로 "bearer"로 설정됨
// }
const accessToken = response.access_token; // 액세스 토큰을 획득!
페이스북 인증 후 PKCE를 거쳐 액세스 토큰을 획득하는 모습
이로써 모든 인증 과정이 안전하게 완료되며, 획득한 액세스 토큰으로 페이스북 API를 호출하거나, id_token
을 활용해 사용자 정보를 확인하여 자체 로그인 처리를 할 수 있습니다.
마무리
이제 다른 서비스의 소셜 로그인을 붙이는 것도 수월하겠지?
이렇게 해서 PKCE가 적용된 OIDC 인증 과정을 페이스북 로그인 구현을 통해 살펴보았습니다.
저도 이 글을 정리하면서 단순히 소셜 로그인 으로만 알고 있었던 개념들 속에 숨겨진 여러 보안 메커니즘을 이해하게 되었습니다. 그리고 이러한 보안 메커니즘이 도입된 이유와 그 배경에 있는 고민들을 이해하게 되니, 단순히 기능을 구현하는 것을 넘어 보안 원리를 이해하고 적용하는 것이 얼마나 중요한지 깨닫게 되었습니다.
처음에는 저도 낯선 용어들을 마주치며 막막함을 느꼈지만, 하나씩 개념을 정리하고 실제로 코드를 작성해보면서 베일에 싸여 있던 OAuth 2.0, OIDC, PKCE의 개념이 점차 명확해졌습니다. 이러한 과정을 통해 소셜 로그인 구현에 대한 자신감도 얻을 수 있었습니다.
이제는 누군가 제게 소셜 로그인 구현에 대해 묻는다면, 좀 더 자신 있게 안전한 방향을 제시할 수 있을 것 같습니다.