Passport.js

passport는 javascript에서 인증처리를 간단하게 해주는 라이브러리입니다. 인증은 다양한 방식으로 이루어지는데 요즘은 인증을 타 서비스로 위임하는 OAuth 방식을 많이 활용하곤 합니다. 저희가 많이 사용하는 구글로그인, 카카오로그인 등이 OAuth방식을 이용하는데요. 구글이나 카카오 등으로 인증 요청을 보내고 해당 인증서버에서 인증을 확인한 후 토큰을 발급받아 다시 사용자의 정보를 가져오는 방식입니다. 이 글의 목적이 로그인방식에 대해 다루는 것이 아닌만큼 이 부분에 대해서는 생략하겠습니다. 이처럼 여런 방법의 인증을 지원하기 위해 Passport는 Strategy라는 인터페이스를 제공합니다. 이 인터페이스의 규격에 맞춰 Strategy를 구현하고 passport에 넘겨주면 passport가 해당 방식에 맞춰 로그인을 처리해줍니다. OAuth2.0의 경우 passport-strategy라이브러리를 상속받은 passport-oauth2가 있고 이 모듈을 상속받아 각 서비스에 맞춰 개조한 passport-facebook, passport-steam 등의 Strategy가 있습니다.

Passport-42

구글이나 카카오처럼 42도 OAuth방식을 지원합니다. 그리고 누군가 만든 passport-42 라이브러리도 있습니다. 저는 사용해본적이 없습니다만, 우연히 슬랙에서 passport-42를 사용하는데 오류가 났다는 글을 발견했습니다. 하지만 질문하신 분께서 이후 같은 문제가 다시 발생하지 않아 어디서 문제가 발생했던 건지를 확인할 수가 없었습니다. 그래서 직접 passport-42를 사용해 간단한 어플리케이션을 만들어보고 코드를 뜯어보며 어디서 문제가 발생했을지를 살펴보았고, passport에 대해 좀 더 이해할 수 있게되어 정리해봤습니다. (근데 42 로그인은 실패함)

코드를 보자!

Passport-42

보통 저는 타입스크립트를 쓰다보니 vim에서 커맨드를 입력해 인터페이스나 타입이 어떻게 정의되었는지를 열어보는데, passport-42는 types가 정의되어있지 않아 직접 구현 코드를 열어보는 수 밖에 없었습니다.(이래서 동적타입 언어란..) passport-42는 다음과 같은 구조로 이루어져있습니다.

passport-42
    ├── lib
    │   ├── index.js
    │   ├── profile.js
    │   └── strategy.js
    ├── test
    ├── package.json
    └── 기타 등등

대부분의 passport strategy 라이브러리들은 이런식으로 lib폴더 안에 strategy.js파일이 있습니다. 파일을 열어보겠습니다.

// lib/strategy.js

// Load modules.
var OAuth2Strategy = require('passport-oauth2');
var util = require('util');
var Profile = require('./profile');
var InternalOAuthError = require('passport-oauth2').InternalOAuthError;

맨 위에 varrequire를 쓰는 쉰내나는 import문이 반겨주네요. 보시는 것 처럼 passport-oauth2 Strategy를 불러와 사용하고 있습니다. 40줄 가량의 친절한 주석 밑에 Strategy가 정의되어있습니다.

function Strategy(options, verify) {
  options = options || {};
  options.authorizationURL = options.authorizationURL ||
    '<https://api.intra.42.fr/oauth/authorize>';
  options.tokenURL = options.tokenURL || '<https://api.intra.42.fr/oauth/token>';
  options.customHeaders = options.customHeaders || {};

  if (!options.customHeaders['User-Agent']) {
    options.customHeaders['User-Agent'] = options.userAgent || 'passport-42';
  }

  OAuth2Strategy.call(this, options, verify);
  this.name = '42';
  this._profileURL = options.profileURL || '<https://api.intra.42.fr/v2/me>';
  this._profileFields = options.profileFields || null;
  this._oauth2.useAuthorizationHeaderforGET(true);
}

// Inherit from `OAuth2Strategy`.
util.inherits(Strategy, OAuth2Strategy);

option을 받아서 값이 없으면 42api URL을 기본값으로 넣어주고 call을 이용해 OAuth2Strategy를 상속받습니다. javascript의 this나 call, apply, bind 등의 개념에 익숙하지 않으시면 낯설게 느껴지실 수 있지만, 쉽게 말해 OAuth2Strategy라는 객체의 속성을 불러와 사용하겠다는 이야기로 보시면 됩니다. 그 아래부터는 this를 활용해 OAuth2Strategy의 속성을 가져와 변조하고 있습니다. 이것만 봐서는 어떻게 동작하는지 알기가 어렵습니다. require로 불러온 passport-oauth2 라이브러리를 살펴봐야겠습니다.

Passport-oauth2

이름에서 볼 수 있듯 passport에서 OAuth2.0 방식의 로그인을 도와주는 Strategy입니다. 앞에서 처럼 lib/strategy.js파일을 열어보겠습니다.

// Load modules.
var passport = require('passport-strategy')
  , url = require('url')
  , util = require('util')
  , utils = require('./utils')
  , OAuth2 = require('oauth').OAuth2
  , NullStateStore = require('./state/null')
  , SessionStateStore = require('./state/session')
  , AuthorizationError = require('./errors/authorizationerror')
  , TokenError = require('./errors/tokenerror')
  , InternalOAuthError = require('./errors/internaloautherror');

이 친구는 passport-strategy라이브러리를 가져와 사용하네요. OAuth로그인에는 oauth라는 라이브러리를 가져와 사용하고 있습니다. 아래로 내려가보면 이 passport의 Strategy객체를 불러오는걸 볼 수 있습니다.

function OAuth2Strategy(options, verify) {
  if (typeof options == 'function') {
    verify = options;
    options = undefined;
  }
  options = options || {};

  if (!verify) { throw new TypeError('OAuth2Strategy requires a verify callback'); }
  if (!options.authorizationURL) { throw new TypeError('OAuth2Strategy requires a authorizationURL option'); }
  if (!options.tokenURL) { throw new TypeError('OAuth2Strategy requires a tokenURL option'); }
  if (!options.clientID) { throw new TypeError('OAuth2Strategy requires a clientID option'); }

  passport.Strategy.call(this);

//...

앞서 말씀드렸던 것 처럼, passport는 strategy를 인터페이스로 제공하고 있습니다. passport-strategy라이브러리의 strategy.js 파일을 열어보면 아래의 코드밖에 없습니다.

/**
 * Creates an instance of `Strategy`.
 *
 * @constructor
 * @api public
 */
function Strategy() {
}

/**
 * Authenticate request.
 *
 * This function must be overridden by subclasses.  In abstract form, it always
 * throws an exception.
 *
 * @param {Object} req The request to authenticate.
 * @param {Object} [options] Strategy-specific options.
 * @api public
 */
Strategy.prototype.authenticate = function(req, options) {
  throw new Error('Strategy#authenticate must be overridden by subclass');
};

/**
 * Expose `Strategy`.
 */
module.exports = Strategy;

JSDoc으로 기능을 설명하고 아무것도 구현돼있지 않습니다. 저도 이 코드를 보면서 js의 독특한 인터페이스 제공방식을 알 게 됐습니다. Strategy객체는 이처럼 authenticate라는 메소드를 공통적으로 갖고 있기 때문에 passport가 등록된 Strategy를 이용해 유저를 인증할 수 있습니다. 다시 passport-oauth2로 돌아가서 authenicate를 어떻게 구현했는지 살펴보겠습니다. passport 인증의 흐름을 보기 위해 임의로 코드를 요약했습니다.

OAuth2Strategy.prototype.authenticate = function(req, options) {
  options = options || {};
  var self = this;

	//...

if (req.query && req.query.code) {
    function loaded(err, ok, state) {

			//...

			self._oauth2.getOAuthAccessToken(code, params,
        function(err, accessToken, refreshToken, params) {

				//...

req는 서버로 들어온 요청을 받는 인자입니다. 서버url/auth?code=인증코드와 같이 get요청이 들어오면 loaded 함수로 보냅니다. 이후 _oauth2라는 private 객체의 getOAuthAccessToken메소드에 인증코드를 담아 실행시킵니다. AccessToken을 받아오는 함수일텐데 이 _oauth2 객체는 무엇일까요? 다시 위로 조금 올라가 보면 쉽게 발견할 수 있습니다.

// ...

			this._oauth2 = new OAuth2(options.clientID,  options.clientSecret,
      '', options.authorizationURL, options.tokenURL, options.customHeaders);

// ...

앞서 불러온 모듈 중에 oauth 모듈이 있었습니다. 입력받은 인자를 활용해 OAuth2객체를 생성해 활용합니다. AccessToken을 어떻게 가져오는지 보기 위해 oauth 라이브러리 코드를 봐야겠습니다.

Oauth

깃허브 레포지토리 이름은 node-oauth입니다. lib폴더의 oauth2.js파일에 OAuth2함수가 정의돼있습니다. 182줄에 getOAuthAccessToken메소드의 코드가 있습니다.

exports.OAuth2.prototype.getOAuthAccessToken= function(code, params, callback) {
  var params= params || {};

	//...

	this._request("POST", this._getAccessTokenUrl(), post_headers, post_data, null, function(error, data, response) {
    if( error )  callback(error);
		else {
			//...

			var access_token= results["access_token"];
      var refresh_token= results["refresh_token"];
      delete results["refresh_token"];
      callback(null, access_token, refresh_token, results); // callback results =-=
    }
	});
}

_request메소드로 토큰 url에 post요청을 보내 AccessToken과 RefreshToken을 받은 뒤 콜백함수로 전달해줍니다. 코드를 좀 더 살펴보면 _request메소드는 _executeRequest를 실행하고 _executeRequesthttps모듈을 사용해 네트워크 요청을 보냅니다.

프로필 가져오기

passport-42는 42유저의 프로필정보도 함께 가져오는데요, passport-oauth2라이브러리는 AccessToken을 받아온 뒤 _loadUserProfile함수를 실행해 유저의 프로필정보를 가져오도록 하는데 실제로 프로필을 가져오는 userProfile함수는 딱히 구현돼있지 않고 인터페이스만 제공하고 있습니다.

// passport-oauth2/lib/strategy.js

OAuth2Strategy.prototype.userProfile = function(accessToken, done) {
  return done(null, {});
};

//...

OAuth2Strategy.prototype._loadUserProfile = function(accessToken, done) {
  var self = this;

  function loadIt() {
    return self.userProfile(accessToken, done);
  }
  function skipIt() {
    return done(null);
  }

	//...

구글이나 페이스북 등 passport로 각 서비스의 oauth2로그인을 지원하는 라이브러리들은 이 userProfile함수를 구현해 AccessToken을 받아옴과 동시에 profile도 같이 불러옵니다. 아래는 passport-42의 userProfile메소드입니다. Profile객체의 코드는 profile.js파일에 있습니다. 그냥 받아온 값을 파싱하는 함수이기 때문에 따로 다루지 않겠습니다.

// passport-42/lib/strategy.js

Strategy.prototype.userProfile = function(accessToken, done) {
  var fields = this._profileFields;
  this._oauth2.get(this._profileURL, accessToken, function (err, body) {
    var json;

    if (err) {
      if (err.data) {
        try {
          json = JSON.parse(err.data);
        } catch (_) {
        // nothing
        }
      }
      if (json && json.message) {
        return done(new InternalOAuthError(json.message, err));
      }
      return done(new InternalOAuthError('Failed to fetch user profile', err));
    }

    try {
      json = JSON.parse(body);
    } catch (ex) {
      return done(new Error('Failed to parse user profile'));
    }

    var profile = Profile.parse(json, fields);
    profile.provider = '42';
    profile._raw = body;
    profile._json = json;

    done(null, profile);
  });
};

결론

passport-42라이브러리도 써보고 passport-oauth2라이브러리도 써보면서 42로그인을 시도해봤는데, 네트워크 오류가 발생하며 계속해서 실패했습니다. 한참 헤매다가 passport-oauth2라이브러리를 사용해 카카오 로그인을 시도해봤는데 바로 성공해서 좀 당황스러웠습니다.

질문하신 분께서 겪었던 문제는 profile을 받아와 이메일을 사용해 데이터베이스 조회를 하는데 이메일이 null값이 들어온다는 문제였습니다. profile값을 따로 validate하지 않고 바로 profile.email로 값을 조회했음에도 에러가 발생하지 않았다는 건 profile은 값이 들어왔다는 것으로 판단해 passport-42라이브러리 문제라고 생각했는데, 코드상에 큰 문제는 없어보입니다.

다른 strategy 라이브러리들도 찾아봤는데 대체로 만들어진지 7년 정도 됐고, 이후로 큰 업데이트는 없는 것 같습니다. 더불어 strategy들이 typescript를 지원하지 않아 별도로 @types 라이브러리를 설치해줘야하는데, passport-42는 그마저도 없어 불편했습니다. 제가 쓰게된다면 passport-oauth2를 활용해 구현할 것 같습니다.

이번에 passport를 뜯어보면서 js가 어떻게 인터페이스를 제공하고, prototype을 사용해 객체를 사용하는지, 무엇보다 콜백지옥이 뭔지 어렴풋이 알 수 있었습니다.