본문 바로가기

OAuth 2.0과 OIDC 소셜 로그인

Google 권한 코드 흐름을 NestJS와 Passport로 구현하고, OAuth와 OIDC의 경계, 계정 연결, 로컬 세션, 토큰 수명 주기를 안전하게 설계합니다.

앞선 절들에서는 로컬 자격 증명, JWT 인증, RBAC를 각각 다루었습니다. 소셜 로그인은 이 흐름을 없애는 기능이 아니라, 외부 제공자가 확인한 정체성을 우리 서비스의 계정과 로그인 상태로 연결하는 또 하나의 인증 진입점입니다.

여기서는 브라우저가 Google을 거쳐 돌아오는 권한 코드 흐름을 사용합니다. 브라우저는 코드와 상태 값만 운반하고, 코드를 Google 토큰으로 바꾸는 작업과 사용자 정보 조회는 NestJS 서버가 수행합니다.

Google OAuth 권한 코드와 로컬 세션 흐름

state/PKCE 보관·Google redirect·callback 일회 소비·code 교환·subject 조회·로컬 연결/세션의 시간 순서이다.

Nest OAuth · 코드 왕복과 로컬 세션

브라우저가 code를 운반하고, 서버가 검증·교환합니다

본문은 OAuth access token으로 Google UserInfo를 읽는 방식입니다. state·S256 verifier는 짧은 OAuth 트랜잭션 세션에, 로그인 결과는 별도 HttpOnly sid에 둡니다. OIDC ID token 검증은 별도 구현입니다.

Google OAuth 권한 코드 로그인 시퀀스 브라우저가 NestJS 로그인 엔드포인트를 호출한다. NestJS는 일회용 state와 PKCE verifier를 서버 세션에 저장하고 S256 challenge와 정확한 redirect URI를 포함해 Google로 보낸다. Google은 code와 state를 콜백으로 돌려보낸다. NestJS는 state를 검증하고 서버에서 code와 verifier를 token으로 교환한 뒤 UserInfo의 안정적인 subject를 조회한다. 로컬 계정 연결 정책을 적용한 후 고정된 화면으로 리다이렉트하며 HttpOnly 로컬 세션 쿠키를 설정한다. GET /auth/google state + verifier 저장 302 · state + S256 authorize · exact redirect_uri 302 · code + state GET callback POST token · verifier access token Bearer access token stable sub + profile 303 · sid HttpOnly 브라우저 redirect carrier NestJS OAuth confidential client Google 권한 서버 authorize · token Google UserInfo resource server
브라우저 왕복과 서버 토큰 교환, 로컬 로그인 경계 브라우저는Nest와Google리다이렉트를 따라code와state를 운반한다. Nest가 state/verifier를 저장하고 콜백을 검증한 뒤 서버에서code와verifier를token으로교환한다. provider access token으로UserInfo의subject를읽고 로컬계정정책과별도sid세션으로이어간다. 이 흐름은OAuth+UserInfo이며OIDC ID token 서명과nonce검증을수행한다는뜻은아니다. 로컬저장소/세션구현은본문의계약전제다. 브라우저 리다이렉트 서버 교환 · 프로필 조회 우리 서비스 경계 브라우저 → Nest 로그인 시작 요청 Nest 트랜잭션 state · verifier 저장 S256 · 정확한 콜백 Google → 브라우저 인증·동의 → code·state Nest 콜백 검증 state 결합 · 일회용 불일치면 교환 전 거부 Nest → Google code · verifier 교환 provider access token Nest → UserInfo 안정적인 subject 조회 로컬 계정 정책 provider + subject 연결 이메일 자동 병합 금지 로컬 로그인 세션 sid HttpOnly · 고정 URL OIDC 검증은 별도

권한 코드 왕복

  1. 트랜잭션을 만듭니다. 브라우저가 /auth/google을 호출하면 NestJS는 일회용 state와 PKCE verifier를 짧은 서버 세션에 보관합니다.

  2. 정확한 콜백을 지정합니다. NestJS는 S256 challenge와 등록 값과 정확히 같은 redirect_uri를 포함해 Google 권한 endpoint로 리다이렉트합니다.

  3. 브라우저가 code를 운반합니다. 인증과 동의 뒤 Google은 일회용 code와 원래 state를 콜백으로 돌려보냅니다.

  4. 서버가 검증하고 교환합니다. NestJS는 state를 확인한 뒤 서버에서 code, verifier, 같은 redirect_uri로 Google token endpoint를 호출합니다.

  5. provider 정체성을 읽습니다. NestJS는 provider access token으로 UserInfo를 조회하고 안정적인 sub를 얻습니다. Google token은 우리 서비스의 로그인 자격 증명이 아닙니다.

  6. 로컬 경계를 만듭니다. (google, sub) 연결 정책을 적용하고 고정된 완료 화면으로 이동하면서 HttpOnly 로컬 세션 쿠키를 설정합니다. 앱 JWT는 URL이나 localStorage로 보내지 않습니다.

OIDC 변형

openid scope로 ID token을 받는 구현은 요청별 nonce를 저장하고, ID token의 JWKS 서명, 허용 알고리즘, iss, aud, exp, nonce를 모두 검증합니다.

OIDC 내부 연결 키는 검증된 (iss, sub)입니다. 이 OAuth UserInfo 예제가 그 ID token 검증을 대신하지 않습니다.

provider access token은 Google 자원용이고, 로컬 세션은 우리 서비스용입니다. 두 자격 증명의 저장·만료·폐기 정책을 섞지 않습니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 브라우저가 NestJS 로그인 엔드포인트를 호출한다. NestJS는 일회용 state와 PKCE verifier를 서버 세션에 저장하고 S256 challenge와 정확한 redirect URI를 포함해 Google로 보낸다. Google은 code와 state를 콜백으로 돌려보낸다. NestJS는 state를 검증하고 서버에서 code와 verifier를 token으로 교환한 뒤 UserInfo의 안정적인 subject를 조회한다. 로컬 계정 연결 정책을 적용한 후 고정된 화면으로 리다이렉트하며 HttpOnly 로컬 세션 쿠키를 설정한다.
  • 브라우저는 Nest와 Google 리다이렉트를 따라 code와 state를 운반한다. Nest가 state/verifier를 저장하고 콜백을 검증한 뒤 서버에서 code와 verifier를 token으로 교환한다. provider access token으로 UserInfo의 subject를 읽고 로컬 계정 정책과 별도 sid 세션으로 이어간다. 이 흐름은 OAuth+UserInfo이며 OIDC ID token 서명과 nonce 검증을 수행한다는 뜻은 아니다. 로컬 저장소/세션 구현은 본문의 계약 전제다.
관계도의 보충 설명
원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1Google OAuth 권한 코드와 로컬 세션 흐름
27브라우저는 Nest와 Google 리다이렉트를 따라 code와 state를 운반한다. Nest가 state/verifier를 저장하고 콜백을 검증한 뒤 서버에서 code와 verifier를 token으로 교환한다. provider access token으로 UserInfo의 subject를 읽고 로컬 계정 정책과 별도 sid 세션으로 이어간다. 이 흐름은 OAuth+UserInfo이며 OIDC ID token 서명과 nonce 검증을 수행한다는 뜻은 아니다. 로컬 저장소/세션 구현은 본문의 계약 전제다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
일회용 state와 PKCE

로그인 엔드포인트가 state·verifier를 서버 세션에 보관하고 S256 challenge를 포함한 요청을 Google에 보냅니다.

서버 세션: state·verifier · 외부 요청: S256 challenge · redirect URI: 정확한 값콜백을 검증할 일회용 자료가 생깁니다.
code callback 확인

Google의 code·state callback에서 서버가 state를 검증합니다.

입력: code·state · state: 일회용 검증정상 callback만 token 교환으로 진행합니다.
서버 token 교환·subject

서버에서 code와 verifier를 token으로 교환한 뒤 UserInfo의 안정적인 subject를 읽습니다.

교환: 서버 측 · PKCE: verifier · 식별: UserInfo subject외부 계정을 로컬 정책에 사용할 식별자로 확인합니다.
로컬 세션과 redirect

계정 연결 정책 뒤 HttpOnly 로컬 session cookie를 설정하고 고정 화면으로 보냅니다.

쿠키: HttpOnly 로컬 세션 · 화면: 고정 redirect · OIDC ID token 검사: 자동 추가 아님외부 인증 결과가 로컬 로그인 상태로 연결됩니다.

Google OAuth 권한 코드와 로컬 세션 흐름

state/PKCE 보관·Google redirect·callback 일회 소비·code 교환·subject 조회·로컬 연결/세션의 시간 순서이다.

Nest OAuth · 코드 왕복과 로컬 세션

브라우저가 code를 운반하고, 서버가 검증·교환합니다

본문은 OAuth access token으로 Google UserInfo를 읽는 방식입니다. state·S256 verifier는 짧은 OAuth 트랜잭션 세션에, 로그인 결과는 별도 HttpOnly sid에 둡니다. OIDC ID token 검증은 별도 구현입니다.

Google OAuth 권한 코드 로그인 시퀀스 브라우저가 NestJS 로그인 엔드포인트를 호출한다. NestJS는 일회용 state와 PKCE verifier를 서버 세션에 저장하고 S256 challenge와 정확한 redirect URI를 포함해 Google로 보낸다. Google은 code와 state를 콜백으로 돌려보낸다. NestJS는 state를 검증하고 서버에서 code와 verifier를 token으로 교환한 뒤 UserInfo의 안정적인 subject를 조회한다. 로컬 계정 연결 정책을 적용한 후 고정된 화면으로 리다이렉트하며 HttpOnly 로컬 세션 쿠키를 설정한다. GET /auth/google state + verifier 저장 302 · state + S256 authorize · exact redirect_uri 302 · code + state GET callback POST token · verifier access token Bearer access token stable sub + profile 303 · sid HttpOnly 브라우저 redirect carrier NestJS OAuth confidential client Google 권한 서버 authorize · token Google UserInfo resource server
브라우저 왕복과 서버 토큰 교환, 로컬 로그인 경계 브라우저는Nest와Google리다이렉트를 따라code와state를 운반한다. Nest가 state/verifier를 저장하고 콜백을 검증한 뒤 서버에서code와verifier를token으로교환한다. provider access token으로UserInfo의subject를읽고 로컬계정정책과별도sid세션으로이어간다. 이 흐름은OAuth+UserInfo이며OIDC ID token 서명과nonce검증을수행한다는뜻은아니다. 로컬저장소/세션구현은본문의계약전제다. 브라우저 리다이렉트 서버 교환 · 프로필 조회 우리 서비스 경계 브라우저 → Nest 로그인 시작 요청 Nest 트랜잭션 state · verifier 저장 S256 · 정확한 콜백 Google → 브라우저 인증·동의 → code·state Nest 콜백 검증 state 결합 · 일회용 불일치면 교환 전 거부 Nest → Google code · verifier 교환 provider access token Nest → UserInfo 안정적인 subject 조회 로컬 계정 정책 provider + subject 연결 이메일 자동 병합 금지 로컬 로그인 세션 sid HttpOnly · 고정 URL OIDC 검증은 별도

권한 코드 왕복

  1. 트랜잭션을 만듭니다. 브라우저가 /auth/google을 호출하면 NestJS는 일회용 state와 PKCE verifier를 짧은 서버 세션에 보관합니다.

  2. 정확한 콜백을 지정합니다. NestJS는 S256 challenge와 등록 값과 정확히 같은 redirect_uri를 포함해 Google 권한 endpoint로 리다이렉트합니다.

  3. 브라우저가 code를 운반합니다. 인증과 동의 뒤 Google은 일회용 code와 원래 state를 콜백으로 돌려보냅니다.

  4. 서버가 검증하고 교환합니다. NestJS는 state를 확인한 뒤 서버에서 code, verifier, 같은 redirect_uri로 Google token endpoint를 호출합니다.

  5. provider 정체성을 읽습니다. NestJS는 provider access token으로 UserInfo를 조회하고 안정적인 sub를 얻습니다. Google token은 우리 서비스의 로그인 자격 증명이 아닙니다.

  6. 로컬 경계를 만듭니다. (google, sub) 연결 정책을 적용하고 고정된 완료 화면으로 이동하면서 HttpOnly 로컬 세션 쿠키를 설정합니다. 앱 JWT는 URL이나 localStorage로 보내지 않습니다.

OIDC 변형

openid scope로 ID token을 받는 구현은 요청별 nonce를 저장하고, ID token의 JWKS 서명, 허용 알고리즘, iss, aud, exp, nonce를 모두 검증합니다.

OIDC 내부 연결 키는 검증된 (iss, sub)입니다. 이 OAuth UserInfo 예제가 그 ID token 검증을 대신하지 않습니다.

provider access token은 Google 자원용이고, 로컬 세션은 우리 서비스용입니다. 두 자격 증명의 저장·만료·폐기 정책을 섞지 않습니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 브라우저가 NestJS 로그인 엔드포인트를 호출한다. NestJS는 일회용 state와 PKCE verifier를 서버 세션에 저장하고 S256 challenge와 정확한 redirect URI를 포함해 Google로 보낸다. Google은 code와 state를 콜백으로 돌려보낸다. NestJS는 state를 검증하고 서버에서 code와 verifier를 token으로 교환한 뒤 UserInfo의 안정적인 subject를 조회한다. 로컬 계정 연결 정책을 적용한 후 고정된 화면으로 리다이렉트하며 HttpOnly 로컬 세션 쿠키를 설정한다.
  • 브라우저는 Nest와 Google 리다이렉트를 따라 code와 state를 운반한다. Nest가 state/verifier를 저장하고 콜백을 검증한 뒤 서버에서 code와 verifier를 token으로 교환한다. provider access token으로 UserInfo의 subject를 읽고 로컬 계정 정책과 별도 sid 세션으로 이어간다. 이 흐름은 OAuth+UserInfo이며 OIDC ID token 서명과 nonce 검증을 수행한다는 뜻은 아니다. 로컬 저장소/세션 구현은 본문의 계약 전제다.

관계도의 보충 설명

원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1Google OAuth 권한 코드와 로컬 세션 흐름
27브라우저는 Nest와 Google 리다이렉트를 따라 code와 state를 운반한다. Nest가 state/verifier를 저장하고 콜백을 검증한 뒤 서버에서 code와 verifier를 token으로 교환한다. provider access token으로 UserInfo의 subject를 읽고 로컬 계정 정책과 별도 sid 세션으로 이어간다. 이 흐름은 OAuth+UserInfo이며 OIDC ID token 서명과 nonce 검증을 수행한다는 뜻은 아니다. 로컬 저장소/세션 구현은 본문의 계약 전제다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
일회용 state와 PKCE

로그인 엔드포인트가 state·verifier를 서버 세션에 보관하고 S256 challenge를 포함한 요청을 Google에 보냅니다.

서버 세션: state·verifier · 외부 요청: S256 challenge · redirect URI: 정확한 값콜백을 검증할 일회용 자료가 생깁니다.
code callback 확인

Google의 code·state callback에서 서버가 state를 검증합니다.

입력: code·state · state: 일회용 검증정상 callback만 token 교환으로 진행합니다.
서버 token 교환·subject

서버에서 code와 verifier를 token으로 교환한 뒤 UserInfo의 안정적인 subject를 읽습니다.

교환: 서버 측 · PKCE: verifier · 식별: UserInfo subject외부 계정을 로컬 정책에 사용할 식별자로 확인합니다.
로컬 세션과 redirect

계정 연결 정책 뒤 HttpOnly 로컬 session cookie를 설정하고 고정 화면으로 보냅니다.

쿠키: HttpOnly 로컬 세션 · 화면: 고정 redirect · OIDC ID token 검사: 자동 추가 아님외부 인증 결과가 로컬 로그인 상태로 연결됩니다.

OAuth 2.0과 OIDC를 구분하기

OAuth 2.0은 클라이언트가 사용자를 대신해 자원 서버에 접근할 권한을 위임받는 프레임워크입니다. access token의 대상은 Google API 같은 자원 서버이며, 그 토큰 자체가 우리 서비스의 로그인 세션은 아닙니다.

OpenID Connect(OIDC)는 OAuth 2.0 위에 인증 계층을 추가합니다. openid scope를 요청하고 검증된 ID token의 iss와 sub로 사용자를 식별합니다. 따라서 “Google로 로그인”을 엄밀히 OIDC로 구현하려면 ID token 검증과 nonce 검증까지 포함해야 합니다.

이 절의 passport-google-oauth20 예제는 Google token endpoint에서 access token을 받은 뒤 Google UserInfo endpoint에서 프로필을 읽는 OAuth 2.0 + UserInfo 방식입니다. 이 전략이 OIDC ID token의 서명이나 nonce를 검증한다고 가정하면 안 됩니다.

권한 코드 흐름의 참여자는 다음과 같습니다.

  • 브라우저: NestJS와 Google 사이의 리다이렉트를 따라가며 코드와 state를 운반합니다.
  • NestJS OAuth 클라이언트: 트랜잭션을 시작하고, 콜백을 검증하며, 코드를 서버에서 토큰으로 교환합니다.
  • Google 권한 서버: 사용자를 인증하고 동의를 받은 뒤 일회용 권한 코드를 발급합니다.
  • Google 자원 서버: provider access token을 검증하고 UserInfo 프로필을 반환합니다.

브라우저에 Google client secret을 보내거나, 브라우저가 token endpoint를 직접 호출하게 만들지 않습니다. 서버용 웹 클라이언트의 secret은 서버 설정에만 둡니다.


Google OAuth 클라이언트 등록

Google Cloud Console에서 애플리케이션 유형을 웹 애플리케이션으로 만들고, 승인된 리디렉션 URI에 다음 개발용 콜백을 등록합니다.

http://localhost:3000/auth/google/callback

등록 값과 GOOGLE_CALLBACK_URL은 scheme, host, port, path, 대소문자, 마지막 슬래시까지 같아야 합니다. 운영 환경에서는 HTTPS 주소를 사용하고 wildcard나 열린 리다이렉트를 두지 않습니다. 이 서버 리다이렉트 흐름만 사용한다면 “승인된 자바스크립트 원본”은 필요하지 않습니다. Google의 브라우저 JavaScript SDK를 별도로 사용할 때만 해당 origin을 등록합니다.

이 절의 주소와 그림은 접두사 없는 구성의 표기입니다. 기존 app.setGlobalPrefix('api')를 유지한다면 시작 경로는 /api/auth/google, 콜백은 http://localhost:3000/api/auth/google/callback입니다. 이 경우 Google Console의 등록 URI, 아래 GOOGLE_CALLBACK_URL, Nest가 받는 실제 콜백 경로를 모두 그 주소로 맞추세요. 접두사 없는 별도 구성에서는 표시된 /auth/google/callback을 사용합니다.

필요한 패키지를 설치합니다.

npm install @nestjs/passport passport passport-google-oauth20 express-session
npm install --save-dev @types/passport-google-oauth20 @types/express-session

환경 변수에는 provider 설정과 두 종류의 리다이렉트 목적을 분리해 둡니다.

.env
GOOGLE_CLIENT_ID=YOUR_GOOGLE_CLIENT_ID
GOOGLE_CLIENT_SECRET=YOUR_GOOGLE_CLIENT_SECRET
GOOGLE_CALLBACK_URL=http://localhost:3000/auth/google/callback

OAUTH_STATE_SECRET=충분히_길고_무작위인_별도_비밀
FRONTEND_AFTER_LOGIN_URL=http://localhost:5173/login/complete

GOOGLE_CALLBACK_URL은 Google이 돌아오는 OAuth endpoint이고, FRONTEND_AFTER_LOGIN_URL은 로컬 로그인이 끝난 뒤 이동할 고정된 화면입니다. 후자를 요청의 임의 returnTo 값으로 그대로 바꾸지 말고, 허용 목록에서 선택합니다.


state와 PKCE 트랜잭션 저장소 구성

state는 콜백을 로그인 시작 요청에 묶어 CSRF와 로그인 응답 주입을 막습니다. PKCE는 일회용 code_verifier를 가진 클라이언트만 권한 코드를 교환하게 합니다. 현재 passport-oauth2에서 state: true와 pkce: true를 사용하면 상태 값과 S256 verifier를 req.session에 보관하므로, Passport middleware보다 먼저 짧은 서버 측 트랜잭션 세션을 구성해야 합니다.

다음은 기존 main.ts에 OAuth 트랜잭션 세션을 추가할 위치를 보여 줍니다. 현재 파일의 전역 파이프, 선택한 접두사와 다른 설정은 유지하고 app.use(session(...))를 라우트가 초기화되기 전에 추가하세요. 아래 발췌의 접두사 없는 주소를 선택하지 않았다면 앞에서 정한 /api 콜백 설정도 함께 유지합니다.

src/main.ts (OAuth 세션 추가 위치)
import { ConfigService } from '@nestjs/config';
import { NestFactory } from '@nestjs/core';
import session from 'express-session';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  const config = app.get(ConfigService);
  const production = config.get<string>('NODE_ENV') === 'production';

  app.use(
    session({
      name: 'oauth_tx',
      secret: config.getOrThrow<string>('OAUTH_STATE_SECRET'),
      resave: false,
      saveUninitialized: false,
      cookie: {
        httpOnly: true,
        sameSite: 'lax',
        secure: production,
        maxAge: 10 * 60 * 1000,
      },
    }),
  );

  await app.listen(3000);
}

void bootstrap();

기본 MemoryStore는 개발 확인용입니다. 여러 인스턴스가 실행되는 운영 환경에서는 만료와 원자적 삭제를 지원하는 공유 세션 저장소를 연결하고, HTTPS를 종료하는 프록시 뒤에서는 신뢰할 프록시 설정도 함께 맞춥니다.

이 oauth_tx 쿠키는 잠깐 유지되는 OAuth 트랜잭션 저장소입니다. 뒤에서 만드는 우리 서비스의 로그인 세션과는 목적과 수명이 다릅니다.


Google 전략은 provider 정체성만 정규화한다

전략은 Google endpoint와 통신하고 provider 프로필을 작은 내부 형태로 바꿉니다. 계정 생성·연결과 우리 서비스 세션 발급은 AuthService의 책임으로 남깁니다.

src/auth/strategies/google.strategy.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { Profile, Strategy } from 'passport-google-oauth20';

export interface GoogleIdentity {
  provider: 'google';
  subject: string;
  email: string | null;
  emailVerified: boolean;
  displayName: string;
  picture: string | null;
}

@Injectable()
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
  constructor(config: ConfigService) {
    super({
      clientID: config.getOrThrow<string>('GOOGLE_CLIENT_ID'),
      clientSecret: config.getOrThrow<string>('GOOGLE_CLIENT_SECRET'),
      callbackURL: config.getOrThrow<string>('GOOGLE_CALLBACK_URL'),
      scope: ['email', 'profile'],
      state: true,
      pkce: true,
    });
  }

  validate(
    _providerAccessToken: string,
    _providerRefreshToken: string,
    profile: Profile,
  ): GoogleIdentity {
    if (profile.provider !== 'google' || !profile.id) {
      throw new UnauthorizedException('Invalid Google profile');
    }

    const verifiedEmail =
      profile.emails?.find((candidate) => candidate.verified === true)?.value ??
      null;

    return {
      provider: 'google',
      subject: profile.id,
      email: verifiedEmail,
      emailVerified: verifiedEmail !== null,
      displayName: profile.displayName ?? '',
      picture: profile.photos?.[0]?.value ?? null,
    };
  }
}

이 전략에서 profile.id는 Google UserInfo의 안정적인 subject 식별자에 해당합니다. 이메일은 바뀔 수 있으므로 기본키로 사용하지 않습니다. 또한 단순 로그인만 필요하므로 provider access token과 refresh token은 req.user에 넣거나 로그에 출력하지 않고 버립니다.

AuthModule에는 전략을 provider로 등록합니다. 여기서 session: false는 Passport가 사용자를 직렬화하는 로그인 세션을 사용하지 않는다는 뜻입니다. 앞서 만든 Express 세션은 OAuth state와 PKCE verifier를 위한 별도 저장소이므로 여전히 필요합니다.

src/auth/auth.module.ts (관련 부분)
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { PassportModule } from '@nestjs/passport';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { GoogleStrategy } from './strategies/google.strategy';

@Module({
  imports: [
    ConfigModule,
    PassportModule.register({ session: false }),
  ],
  controllers: [AuthController],
  providers: [AuthService, GoogleStrategy],
})
export class AuthModule {}

기존 로컬·JWT 전략과 사용자 저장소 provider가 있다면 같은 배열에 유지합니다.


콜백에서 로컬 계정과 세션을 만든다

같은 AuthGuard('google')가 시작 route에서는 Google로 리다이렉트하고, 콜백 route에서는 state를 검증한 뒤 코드를 서버 측에서 교환하고 UserInfo를 조회합니다. 전략이 반환한 GoogleIdentity가 콜백 요청의 req.user가 됩니다.

앞 절의 전역 JWT·역할 가드를 유지한다면, 아직 앱 token이 없는 Google 시작 요청과 콜백도 @Public()으로 열어야 합니다. 아래 두 메서드에만 붙이고 AuthGuard('google')는 유지합니다. 공개 표시는 전역 앱 인증 검사만 건너뛰며 Google 인증과 state·PKCE 검증을 생략하지 않습니다. Public의 정의와 전역 가드 연결은 앞 절의 첫 로그인 요청과 같습니다.

아래 컨트롤러는 completeGoogleLogin()과 createBrowserSession()을 이미 구현한 프로젝트에서 인증 경계를 읽기 위한 발췌입니다. 앞 절의 AuthService는 로컬 자격 증명과 JWT 발급만 제공하므로 이 두 메서드를 아직 갖고 있지 않습니다. 이 절도 계정 연결 저장소와 로컬 세션 저장소 구현을 제공하지 않으며, 뒤의 정책은 그 메서드가 지켜야 할 계약입니다.

src/auth/auth.controller.ts (Google 관련 부분)
import { Controller, Get, Req, Res, UseGuards } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AuthGuard } from '@nestjs/passport';
import type { Request, Response } from 'express';
import { AuthService } from './auth.service';
import type { GoogleIdentity } from './strategies/google.strategy';
import { Public } from '../common/decorators/public.decorator';

type GoogleCallbackRequest = Request & { user: GoogleIdentity };

@Controller('auth')
export class AuthController {
  constructor(
    private readonly authService: AuthService,
    private readonly config: ConfigService,
  ) {}

  @Public()
  @Get('google')
  @UseGuards(AuthGuard('google'))
  googleLogin(): void {
    // Guard가 권한 요청 URL로 리다이렉트합니다.
  }

  @Public()
  @Get('google/callback')
  @UseGuards(AuthGuard('google'))
  async googleCallback(
    @Req() req: GoogleCallbackRequest,
    @Res() res: Response,
  ): Promise<void> {
    const user = await this.authService.completeGoogleLogin(req.user);
    const session = await this.authService.createBrowserSession(user.id);
    const production = this.config.get<string>('NODE_ENV') === 'production';

    res.cookie('sid', session.token, {
      httpOnly: true,
      sameSite: 'lax',
      secure: production,
      path: '/',
      maxAge: 8 * 60 * 60 * 1000,
    });

    res.redirect(
      303,
      this.config.getOrThrow<string>('FRONTEND_AFTER_LOGIN_URL'),
    );
  }
}

createBrowserSession()은 암호학적으로 무작위인 opaque token을 한 번 반환하고, 저장소에는 그 token의 해시와 사용자 ID, 유휴·최대 만료 시각을 보관하는 메서드라고 가정합니다. 쿠키를 발급하는 것과 다음 요청에서 세션을 인증하는 것은 별개입니다.

완료 화면에서 세션을 확인하려면 sid를 조회하고 만료·폐기를 검사하는 세션 인증 경로와 GET /auth/me 핸들러를 따로 구현해야 합니다. 이 절은 그 구현을 제공하지 않습니다. 앞 절의 전역 JWT 가드는 opaque sid를 자동으로 인증하지 않으므로 JWT 검사와 별도 세션 가드가 어떤 경로에 적용되는지도 명시해야 합니다. 해당 경로를 구현한 뒤에만 완료 화면에서 호출하며, api를 유지했다면 주소는 /api/auth/me입니다.

세션 쿠키를 사용하는 상태 변경 요청에는 SameSite 설정만 믿지 말고 신뢰하는 Origin 확인이나 CSRF token을 적용합니다. 프론트엔드와 API origin이 다르면 정확한 CORS origin, credentials 설정, cookie 정책을 함께 맞춥니다.

계정 연결 정책

completeGoogleLogin()을 구현할 때는 다음 순서를 DB 트랜잭션 안에서 지켜야 합니다. 아래는 계정 연결 정책이며 이 절에 메서드와 저장소 구현이 제공되는 것은 아닙니다.

  1. (provider, subject)에 고유 제약을 두고 이미 연결된 로컬 사용자를 찾습니다.
  2. 연결이 있으면 그 사용자를 로그인시킵니다. 정지·삭제 상태 같은 로컬 정책은 여기서 다시 확인합니다.
  3. 연결은 없지만 같은 이메일의 로컬 계정이 있으면 자동 병합하지 않습니다. 사용자가 기존 방식으로 다시 인증한 뒤 명시적으로 연결하도록 요구합니다.
  4. 충돌이 없고 가입 정책을 만족할 때만 로컬 사용자와 provider 연결 레코드를 원자적으로 생성합니다.

Google이 이메일을 검증했다는 사실만으로 기존 로컬 계정의 소유권까지 증명되지는 않습니다. 안정적인 provider subject를 무시하고 이메일로 자동 연결하면 계정 탈취나 잘못된 병합 경계가 생깁니다.


OIDC로 구현할 때 추가되는 검증

OIDC 구현으로 바꾼다면 단순히 openid scope만 추가해서는 충분하지 않습니다. 검증을 직접 조립하기보다 discovery와 JWKS 검증을 지원하는 검증된 OIDC 클라이언트나 전략을 사용하고, 다음 조건을 모두 검증하고, 하나라도 검증에 실패하면 거부합니다.

  • 권한 요청마다 예측 불가능한 nonce를 만들고 서버의 OAuth 트랜잭션에 저장합니다.
  • ID token의 서명을 provider discovery/JWKS로 검증하고 허용한 알고리즘만 받습니다.
  • iss가 기대한 Google issuer인지, aud가 우리 client ID를 포함하는지 확인합니다. audience가 여러 개라면 azp도 확인합니다.
  • exp와 필요한 시간 claim을 확인하고, ID token의 nonce가 저장한 값과 정확히 같은지 확인한 뒤 한 번만 사용합니다.
  • 내부 연결 키는 이메일이 아니라 검증된 (iss, sub)를 사용합니다.

서명 확인 없이 ID token payload를 decode한 결과를 신뢰하거나, OAuth access token을 우리 서비스의 ID token처럼 사용하면 안 됩니다. 자세한 검증 조건은 OpenID Connect Core와 Google OpenID Connect 문서를 기준으로 삼습니다.


provider token과 로컬 로그인 수명 주기를 분리하기

이 예제는 Google API를 계속 호출하지 않으므로 provider token을 저장하지 않습니다. 캘린더나 Drive 같은 Google API 위임이 실제로 필요할 때만 최소 scope를 요청하고 다음을 별도로 설계합니다.

  • offline access가 필요한 이유를 명확히 하고, Google refresh token이 매번 다시 발급된다고 가정하지 않습니다.
  • provider access/refresh token은 브라우저나 req.user가 아니라 서버의 암호화된 저장소에 scope·만료·provider 계정과 함께 보관합니다.
  • 갱신 응답에 새 refresh token이 없으면 기존 값을 실수로 지우지 않고, invalid_grant 같은 폐기 신호에서는 재동의를 요구합니다.
  • 연결 해제 시 Google grant를 revoke하고 저장된 provider token을 삭제합니다.

우리 서비스의 sid 세션은 Google token과 독립적으로 유휴 만료, 최대 수명, 회전, 로그아웃, 비밀번호·권한 변경 시 폐기를 적용합니다. API가 JWT를 사용한다면 로그인 완료 후 인증된 HTTPS 응답 body나 HttpOnly cookie로 발급하고 access/refresh 정책을 별도로 적용합니다. 앱 JWT를 리다이렉트 query에 넣거나 localStorage로 넘기지 않습니다.

Nest 소셜 로그인 · 신뢰 경계

코드·provider token·ID token·로컬 세션의 용도를 구분합니다

콜백 검증, 안정적인 subject 식별, 계정 연결, 로컬 세션, 폐기를 각각 설계합니다. 같은 이메일이라는 이유만으로 계정을 합치지 않으며 provider token과 앱 자격 증명의 수명을 분리합니다.

콜백

필수 판정과 저장: 등록 값과 정확히 같은 redirect_uri, 일회용·브라우저 세션 결합 state, PKCE S256을 사용합니다. OIDC면 요청별 nonce도 저장합니다.
섞거나 생략했을 때: CSRF, 로그인 응답 주입, 권한 코드 가로채기, 열린 리다이렉트로 이어질 수 있습니다.

식별

필수 판정과 저장: OAuth UserInfo 연결 키는 (google, sub)입니다. 고정된 Google endpoint에서 프로필을 얻습니다. OIDC는 ID token의 서명·알고리즘·iss·aud·exp·nonce를 검증합니다.
섞거나 생략했을 때: 이메일이나 서명 확인 없이 decode한 claim을 신뢰하면 다른 사용자를 잘못 식별할 수 있습니다.

연결

필수 판정과 저장: (provider, subject)에 고유 제약을 두고 트랜잭션으로 연결합니다. 같은 이메일의 기존 계정은 재인증과 명시적 동의 뒤에만 연결합니다.
섞거나 생략했을 때: 이메일 자동 병합은 계정 탈취나 서로 다른 계정의 영구 오연결을 만들 수 있습니다.

세션

필수 판정과 저장: 고정된 완료 URL로 이동하고 opaque 세션을 HttpOnly·Secure·SameSite cookie로 전달합니다. 상태 변경에는 별도 CSRF 방어를 적용합니다.
섞거나 생략했을 때: 앱 JWT를 query나 localStorage에 넣으면 기록·referrer·브라우저 확장과 XSS를 통해 노출 범위가 커집니다.

폐기

필수 판정과 저장: Google API가 필요할 때만 provider token을 암호화 저장하고 scope·만료·grant 폐기를 관리합니다. 로컬 세션은 유휴·최대 만료, 회전, 로그아웃 폐기를 독립 적용합니다.
섞거나 생략했을 때: Google refresh token과 앱 refresh token을 같은 것으로 다루면 한쪽의 만료·연결 해제·보안 이벤트가 다른 쪽에 올바르게 반영되지 않습니다.

안전한 소셜 로그인은 “Google 인증 성공” 한 단계가 아니라, 외부 트랜잭션을 검증된 로컬 사용자와 폐기 가능한 로컬 로그인 상태로 좁혀 가는 연속된 경계입니다.


실패 경로와 테스트

성공 화면만 확인하지 말고 보안 경계를 각각 실패시켜 봅니다.

/auth/google에 접속하고 권한 요청 URL에 예측 불가능한 state와 S256 code_challenge가 있는지 확인합니다. 콜백 URL은 등록한 값과 정확히 같아야 합니다.

콜백의 state를 바꾸거나 같은 콜백을 재사용합니다. 요청은 거부되고 로컬 계정과 세션이 만들어지지 않아야 합니다.

정상 로그인 뒤 주소 표시줄에 앱 token이 없는지, 응답의 sid가 HttpOnly인지, 고정된 완료 URL로만 이동하는지 확인합니다. 서버 로그에도 provider token과 전체 프로필을 남기지 않습니다.

기존 로컬 계정과 같은 이메일이지만 아직 연결되지 않은 Google 계정으로 로그인합니다. 자동 병합하지 않고 기존 계정 재인증 절차로 보내야 합니다.

OIDC 변형에서는 서명, iss, aud, exp, nonce 중 하나라도 맞지 않는 ID token을 모두 거부합니다.

로컬 로그아웃과 Google 연결 해제를 따로 시험합니다. 전자는 우리 세션을 폐기하고, 후자는 필요한 경우 provider grant까지 revoke해야 합니다.

리다이렉트 URI exact matching, PKCE, 응답 주입 방어의 최신 기준은 OAuth 2.0 Security Best Current Practice, Google token 교환과 폐기 동작은 Google 웹 서버 OAuth 흐름을 함께 확인합니다.


이제 4장의 인증 흐름은 하나로 이어집니다. 로컬 로그인과 소셜 로그인은 서로 다른 정체성 증명 경로이고, 둘 다 검증된 로컬 사용자 컨텍스트를 만든 뒤 세션 또는 앱 token 경계로 넘어갑니다. RBAC는 그다음 요청에서 인증된 사용자에게 어떤 작업을 허용할지 판정합니다.