OAuth 2.0으로 구현하는 SNS 로그인 — Sign in with Google (2) 서버개발

woo94
dev-woo94
Published in
15 min readMay 12, 2024

Sign in with Google은 사용자가 Google로 로그인하기 버튼을 누르면서 시작이 됩니다. 하지만 개발 단순화를 위해 UI를 만들지 않고 구현해보겠습니다.

프로젝트 초기화

import express from 'express'

const PORT = process.env.PORT

const app = express()

app.listen(Number(PORT), () => {
console.log(`server is running on port ${PORT}`)
})

PORT라는 환경변수에 적힌 값을 포트로 사용하는 express 서버를 구성했습니다.

Authorization request route 생성

UI를 만들지 않고 브라우저를 사용하여 sign in with Google authorization flow를 시작하기 위해서는 route는 GET method로 되어야 합니다. Authorization request에 필요한 parameter들을 설정해준 다음 해당 endpoint로 redirect 시켜줍니다.

import qs from 'qs'

const googleClientID = process.env.GOOGLE_OAUTH_CLIENT_ID ?? '';
const googleRedirectUri = process.env.GOOGLE_REDIRECT_URI ?? '';

app.get('/sign-in-with-google', (req, res, next) => {
const url = new URL('https://accounts.google.com/o/oauth2/v2/auth');

const queryParams = qs.stringify({
client_id: googleClientID,
redirect_uri: googleRedirectUri,
response_type: 'code',
scope: 'openid email',
prompt: 'consent',
});

url.search = queryParams;

res.redirect(url.toString());
});

Sign in with Google을 통해 어떤 Google API를 사용하려는 것이 아니라 사용자를 식별해주기 위해서기 때문에 scope: 'openid email' 의 2개의 값을 넣어주었습니다.

Authorization code flow를 사용하기 위해 response_type: 'code' 를 사용했습니다.

나머지 2개의 required field인 client_idredirect_uri 도 설정해주었습니다.

마지막으로는 optional parameter인 prompt: ‘consent' 로 prompt를 설정해주었습니다. 이 값을 설정해주지 않으니 한번 로그인 한 이후에 계속해서 sign in with Google 화면이 지나갔습니다. 브라우저에서 token을 가지고 있어서 자동으로 로그인을 건너뛰는 것 같았습니다. 실험한 결과 revoke token API를 호출해줘야지만 다시 login flow를 볼 수 있었습니다. 따라서 매번 페이지에 접속할때마다 sign in with Google 화면을 보기 위해서 prompt: consent 의 값을 설정해줬습니다.

이제 브라우저에서 $domain/sign-in-with-google 로 접속하니 아래의 화면이 나옵니다.

제가 만든 Google Cloud Console의 project 이름인 “OAuth test” 값이 보입니다.

Authentication redirect route 생성

Sign in with Google authorization flow를 마치고 난 다음에는 Authorization request시에 parameter로 넣어준 redirect_uri 로 authorization response가 전송됩니다. response_type: 'code' 로 해주었기 때문에 authorization code 가 전송될 예정입니다. 이제 이 code를 token으로 교환시켜줄 route를 생성해보겠습니다.

(1)환경설정 글에서 redirect URI를 추후 수정한다고 설명드렸습니다. 이제 redirect route를 생성할것이므로 이 route로 redirect URI를 설정해줍니다.

$domain/oauth2/google 로 설정해줍니다.

Route에 대한 코드는 아래와 같습니다:

import cors from 'cors'

app.use(cors());
app.use(express.urlencoded({extended: true}));

const googleClientSecret = process.env.GOOGLE_OAUTH_CLIENT_SECRET ?? '';

app.get('/oauth2/google', async (req, res, next) => {
try {
console.log(req.query);
const params = new URLSearchParams({
client_id: googleClientID,
client_secret: googleClientSecret,
code: req.query.code as string,
grant_type: 'authorization_code',
redirect_uri: googleRedirectUri,
});

const tokenRequest = await axios.post(
`https://oauth2.googleapis.com/token`,
params.toString()
);

console.log('token exchange result', tokenRequest.data);

res.status(200).send('ok');
} catch (e) {
next(e);
}
});

Sign in with Apple은 authorization response를 POST method로 redirect uri에 전송하는 것과 달리 sign in with Google은 GET method를 사용합니다. 따라서 데이터들은 query parameter의 형태로 들어가게 되고 이것을 출력해보니 아래와 같습니다:

{
code: '4/0AdLIrYelGorUFn6gMxT0wPSKUsf1t2AIIHa2I80yZwHAVQdW9tJ4-xYCxcj2B6j-s1AeRA',
scope: 'email openid https://www.googleapis.com/auth/userinfo.email',
authuser: '0',
prompt: 'consent'
}

이제 token exchange를 위해 아래 주소로 authorization code를 전달해줍니다:

POST 

https://oauth2.googleapis.com/token

Parameter들로 Authorization request시에 사용한 client_id, redirect_uri 를 사용하고, grant_type: 'authorization_code' 을 고정으로 사용합니다.

client_secret 값은 반드시 서버에만 안전하게 저장해두어야 합니다. 저는 이 값을 환경변수로 관리합니다.

마지막으로 query parameter로 전달된 code 값을 넣어줍니다.

이번에도 sign in with Apple 때와 마찬가지로 content type을 application/x-www-form-urlencoded 로 해줍니다.

이 요청에 대한 응답은 아래와 같았습니다:

{
access_token: ...,
expires_in: 3599,
scope: "openid https://www.googleapis.com/auth/userinfo.email",
token_type: "Bearer",
id_token: ...
}

id_token은 JWT로, decode가 가능합니다. Decode한 payload는 아래와 같습니다:

{
"iss": "https://accounts.google.com",
"azp": "854684572407-ciepajn0fvpk2l3scrqmvo4gl2q7e85h.apps.googleusercontent.com",
"aud": "854684572407-ciepajn0fvpk2l3scrqmvo4gl2q7e85h.apps.googleusercontent.com",
"sub": "116762859018120769559",
"email": "bestman21c@gmail.com",
"email_verified": true,
"at_hash": "IybJ07z6p7-Gmw7Iec8Ucg",
"iat": 1715503445,
"exp": 1715507045
}

저의 이메일 주소를 잘 알아오는 것을 확인했습니다. Google의 경우에는 “sub” claim에 대한 별도의 설명이 되어있지 않습니다. id_token 속의 email 주소를 user identifier로 사용해도 좋을 것 같습니다.

이제 사용자의 이메일 주소를 알았으니 이를 통해 당신의 서비스에서 authentication 을 진행하고, 서비스 만의 JWT를 발행하여 Client에게 전달 할 수 있습니다. 지금은 그 과정이 생략되어 res.status(200).send('ok') 를 응답으로 보내게끔 되어있습니다.

Client secret을 sign in with Apple 보다 쉽게 생성할 수 있어서 훨씬 간단하게 서버를 구성할 수 있었습니다.

전체코드

import express, {ErrorRequestHandler} from 'express';
import morgan from 'morgan';
import {google} from 'googleapis';
import axios from 'axios';
import cors from 'cors';
import 'dotenv/config';
import {readFileSync} from 'fs';
import path from 'path';
import jwt from 'jsonwebtoken';
import url from 'node:url';
import qs from 'qs';
import {nanoid} from 'nanoid';

const app = express();

app.use(cors());
app.use(express.json());
// application/x-www-form-encoded 형태의 request body를 parsing 하기 위해 필요
app.use(express.urlencoded({extended: true}));

const port = process.env.PORT as string;
console.log(port);

app.get('/', (req, res, next) => {
res.status(200).end();
});

app.use(morgan('dev'));

const googleClientID = process.env.GOOGLE_OAUTH_CLIENT_ID ?? '';
const googleRedirectUri = process.env.GOOGLE_REDIRECT_URI ?? '';
const googleClientSecret = process.env.GOOGLE_OAUTH_CLIENT_SECRET ?? '';

app.get('/sign-in-with-google', (req, res, next) => {
const url = new URL('https://accounts.google.com/o/oauth2/v2/auth');

const queryParams = qs.stringify({
client_id: googleClientID,
redirect_uri: googleRedirectUri,
response_type: 'code',
scope: 'openid email',
prompt: 'consent',
});

url.search = queryParams;

res.redirect(url.toString());
});

app.get('/oauth2/google', async (req, res, next) => {
try {
console.log(req.query);
const params = new URLSearchParams({
client_id: googleClientID,
client_secret: googleClientSecret,
code: req.query.code as string,
grant_type: 'authorization_code',
redirect_uri: googleRedirectUri,
});

const tokenRequest = await axios.post(
`https://oauth2.googleapis.com/token`,
params.toString()
);

console.log('token exchange result', tokenRequest.data);

res.status(200).send('ok');
} catch (e) {
next(e);
}
});


const errorHandler: ErrorRequestHandler = async (err, req, res, next) => {
console.log(err);
res.status(500).json(err);
};

app.use(errorHandler);

app.listen(Number(port), () => {
console.log(`server is running on port ${port}`);
});

환경변수 파일(.env)

# Server
PORT = "8080"

# Google
GOOGLE_OAUTH_CLIENT_ID = ... # OAuth Client ID
GOOGLE_OAUTH_CLIENT_SECRET = ... # OAuth Client secret
GOOGLE_REDIRECT_URI = ... # Where authorization redirects

Reference

--

--