Passport.js 소스 코드 톺아보기

원민관·2025년 8월 6일

[TIL]

목록 보기
188/201
post-thumbnail

0. Overview ✍️

프로젝트를 배포하기 전에, Pullim의 주요 feature를 다이어그램 형식으로 정리하고 있었습니다.

  1. 회원가입
  2. 로그인
  3. Amazon S3를 통한 이미지 처리
  4. Gemini 2.5 Pro를 적용한 질문 답변
  5. 페이지네이션

총 5개의 feature를 정리할 생각이었습니다.

그런데 로그인 부분에서 일종의 '학습 병목'이 발생했습니다. 세션 방식으로 로그인을 구현하는 부분에서, passport의 내부 동작 원리를 파악하지 못해 flow를 그리지 못하는 상황이 발생했더랬죠.


(req as any).login()

위 코드에서 req에 대한 login() 메서드가 언제 생성되는지, login() 메서드가 실행되면 어떤 일이 벌어지는지 명확히 알지 못하고 있는 상태였습니다. 더욱이 공식 문서에서는 "로그인하면 로그인 됩니다." 수준의 설명만을 제공하고 있었습니다.

한마디로 Passport를 사용한다면서 Passport가 무엇인지 전혀 모르는 상태였습니다.

그래서 Passport.js의 소스 코드를 직접 까보기로 결심했습니다. 관련해서, nest.js의 소스 코드 일부도 확인했습니다.


reference: https://github.com/jaredhanson/passport/tree/master
reference: https://github.com/nestjs/passport/tree/master/lib/passport

1. Authenticator ✍️

1-1. 생성자 🎯

import * as passport from 'passport';

console.log(passport);

코드를 읽을 때, Entry Point(=진입점)가 어디인지 파악하는 작업은 꽤 중요합니다. 아무리 길고 복잡한 코드도 결국 하나의 Entry Point로부터 시작되고, 차근차근 정복하면 파악하지 못할 코드는 없습니다.

Passport.js 소스 코드를 봐야 하는데, 오픈 소스를 읽어본 경험이 많지 않은 터라 어디서부터 읽어야 할지 감이 오지 않았습니다. 그래서 우선 passport를 로그로 확인했습니다.


Authenticator {
  _key: 'passport',
  _strategies: {
    session: SessionStrategy {
      name: 'session',
      _key: 'passport',
      _deserializeUser: [Function: bound]
    }
  },
  _serializers: [],
  _deserializers: [],
  _infoTransformers: [],
  _framework: {
    initialize: [Function: initialize],
    authenticate: [Function: authenticate]
  },
  _sm: SessionManager {
    key: 'passport',
    _serializeUser: [Function: bound]
  },
  Authenticator: [Function: Authenticator],
  Passport: [Function: Authenticator],
  Strategy: <ref *1> [Function: Strategy] {
    Strategy: [Circular *1]
  },
  strategies: {
    SessionStrategy: [Function: SessionStrategy]
  }
}

Authenticator 클래스의 인스턴스 객체 구조가 출력되는 것을 확인할 수 있었습니다.


passport/lib/authenticator.js 파일을 확인해 보는 것이 논리적으로 타당했습니다. 현재 주목해야 할 주제는 authenticator니까요.

function Authenticator() {
  this._key = 'passport';
  this._strategies = {};
  this._serializers = [];
  this._deserializers = [];
  this._infoTransformers = [];
  this._framework = null;
  
  this.init();
}

6가지 속성을 초기화하고 있네요. 추가적으로 init()이라는 함수를 실행합니다.

Authenticator.prototype.init = function() {
  this.framework(require('./framework/connect')());
  this.use(new SessionStrategy({ key: this._key }, this.deserializeUser.bind(this)));
  this._sm = new SessionManager({ key: this._key }, this.serializeUser.bind(this));
};

같은 경로에서 init()의 동작을 확인할 수 있었습니다. 세 가지 동작을 처리한다는 점을 볼 수 있습니다.

요컨대, passport를 가져오는 순간 생성자 함수가 실행되며 6가지 세부 속성을 초기화하고 init() 함수를 실행합니다.

1-2. framework 🎯

this.framework(require('./framework/connect')());

먼저 ./framework/connect 파일을 실행한 뒤 반환되는 값을 framework라는 메서드에 인자로 전달하고 있습니다. 해당 파일을 확인해 봐야겠습니다.

var initialize = require('../middleware/initialize')
  , authenticate = require('../middleware/authenticate');
  
exports = module.exports = function() {
  
  return {
    initialize: initialize,
    authenticate: authenticate
  };
};

해당 파일에서는 initialize와 authenticate라는 미들웨어를 객체의 형태로 반환하고 있습니다. 본 글에서 사용하는 미들웨어라는 용어는, 요청과 응답 사이에서 실행되는 함수를 의미합니다.


결론적으로 framework 메서드에 전달되는 인자의 값은 객체입니다.

Authenticator.prototype.framework = function(fw) {
  this._framework = fw;
  return this;
};

fw에 들어오는 값은 객체겠죠. 해당 객체를 Authenticator의 _framework 속성에 추가하고, 추가된 버전의 Authenticator를 반환합니다.

init() 함수의 첫 번째 작업은 Authenticator의 _framework 속성 업데이트라고 이해할 수 있겠습니다. Authenticator를 다시 살펴보시죠.

Authenticator {
  _key: 'passport',
  _strategies: {
    session: SessionStrategy {
      name: 'session',
      _key: 'passport',
      _deserializeUser: [Function: bound]
    }
  },
  _serializers: [],
  _deserializers: [],
  _infoTransformers: [],
  _framework: {
    initialize: [Function: initialize],
    authenticate: [Function: authenticate]
  },
  _sm: SessionManager {
    key: 'passport',
    _serializeUser: [Function: bound]
  },
  Authenticator: [Function: Authenticator],
  Passport: [Function: Authenticator],
  Strategy: <ref *1> [Function: Strategy] {
    Strategy: [Circular *1]
  },
  strategies: {
    SessionStrategy: [Function: SessionStrategy]
  }
}

1-3. SessionStrategy 🎯

이제 init()의 두 번째 작업에 대해 살펴보겠습니다.

this.use(new SessionStrategy({ key: this._key }, this.deserializeUser.bind(this)));

SessionStrategy라는 인스턴스를 생성하며 { key: this._key }this.deserializeUser.bind(this)를 전달하고 있습니다. 그렇게 해서 생성된 인스턴스를 use라는 메서드의 인자로 넘겨주고 있네요.

this._key는 생성자에서 'passport'로 초기화했기에, 사실상 첫 번째 인자는 { key: 'passport' }라는 점을 알 수 있습니다.


자연스럽게 passport/lib/strategies/session.js 파일을 확인하게 되었습니다.

var pause = require('pause')
  , util = require('util')
  , Strategy = require('passport-strategy');
.
.
.
function SessionStrategy(options, deserializeUser) {
  if (typeof options == 'function') {
    deserializeUser = options;
    options = undefined;
  }
  options = options || {};
  
  Strategy.call(this);
  
  this.name = 'session';
  this._key = options.key || 'passport';
  this._deserializeUser = deserializeUser;
}

options에는 객체가 전달되었으니 조건문은 통과합니다.

Strategy.call()은, 부모 생성자인 Strategy의 생성자 로직을 현재 인스턴스(this)에 적용하는 로직입니다. ES5의 문법입니다. 현재 ES6에서는 super()를 사용합니다.

SessionStrategy라는 인스턴스가 생성되며 부모 생성자인 Strategy의 내용을 상속받고 name_key, 그리고 deserializeUser 속성을 초기화합니다.


Authenticator.prototype.use = function(name, strategy) {
  if (!strategy) {
    strategy = name;
    name = strategy.name;
  }
  if (!name) { throw new Error('Authentication strategies must have a name'); }
  
  this._strategies[name] = strategy;
  return this;
};

이제 SessionStrategy 인스턴스를 use에 전달합니다. name 파라미터에 인스턴스가 전달됩니다.

조건문에 의해 strategy에는 SessionStrategy의 인스턴스가 할당되고, name에는 SessionStrategy의 name인 'session'이 할당됩니다.

Authenticator의 _strategies라는 객체에 session이 key이고 SessionStrategy가 value인 프로퍼티가 추가되는 것이죠. Authenticator를 다시 살펴보시죠.

Authenticator {
  _key: 'passport',
  _strategies: {
    session: SessionStrategy {
      name: 'session',
      _key: 'passport',
      _deserializeUser: [Function: bound]
    }
  },
  _serializers: [],
  _deserializers: [],
  _infoTransformers: [],
  _framework: {
    initialize: [Function: initialize],
    authenticate: [Function: authenticate]
  },
  _sm: SessionManager {
    key: 'passport',
    _serializeUser: [Function: bound]
  },
  Authenticator: [Function: Authenticator],
  Passport: [Function: Authenticator],
  Strategy: <ref *1> [Function: Strategy] {
    Strategy: [Circular *1]
  },
  strategies: {
    SessionStrategy: [Function: SessionStrategy]
  }
}

1-4. SessionManager 🎯

init()이 수행하는 마지막 작업을 살펴보겠습니다.

this._sm = new SessionManager({ key: this._key }, this.serializeUser.bind(this));

Authenticator의 _sm 속성에 SessionManager 인스턴스를 할당하고 있네요. passport/lib/sessionmanager.js를 보면 되겠습니다.

function SessionManager(options, serializeUser) {
  if (typeof options == 'function') {
    serializeUser = options;
    options = undefined;
  }
  options = options || {};
  
  this._key = options.key || 'passport';
  this._serializeUser = serializeUser;
}

_key_serializeUser 속성을 초기화하며 SessionManager 인스턴스를 생성합니다.

Authenticator {
  _key: 'passport',
  _strategies: {
    session: SessionStrategy {
      name: 'session',
      _key: 'passport',
      _deserializeUser: [Function: bound]
    }
  },
  _serializers: [],
  _deserializers: [],
  _infoTransformers: [],
  _framework: {
    initialize: [Function: initialize],
    authenticate: [Function: authenticate]
  },
  _sm: SessionManager {
    key: 'passport',
    _serializeUser: [Function: bound]
  },
  Authenticator: [Function: Authenticator],
  Passport: [Function: Authenticator],
  Strategy: <ref *1> [Function: Strategy] {
    Strategy: [Circular *1]
  },
  strategies: {
    SessionStrategy: [Function: SessionStrategy]
  }
}

1. passport를 불러오는 순간 Authenticator가 생성됩니다.
2. Authenticator의 _framework 속성을 업데이트합니다.
3. Authenticator의 _strategies 속성을 업데이트합니다.
4. Authenticator의 _sm 속성을 업데이트합니다.
=> passport는, 내부적으로 Authenticator입니다.

2. passport.initialize() ✍️

passport는 내부적으로 Authenticator입니다. 따라서 passport.initialize()는 Authenticator가 initialize() 메서드를 사용하겠다는 말과 정확하게 일치합니다.

Authenticator.prototype.initialize = function(options) {
  options = options || {};
  return this._framework.initialize(this, options);
};

우리가 업데이트했던 _framework의 initialize를 사용하겠다고 합니다.

var IncomingMessageExt = require('../http/request');

module.exports = function initialize(passport, options) {
  options = options || {};
  
  return function initialize(req, res, next) {
    req.login =
    req.logIn = req.logIn || IncomingMessageExt.logIn;
    req.logout =
    req.logOut = req.logOut || IncomingMessageExt.logOut;
    req.isAuthenticated = req.isAuthenticated || IncomingMessageExt.isAuthenticated;
    req.isUnauthenticated = req.isUnauthenticated || IncomingMessageExt.isUnauthenticated;
    
    req._sessionManager = passport._sm;
    
    if (options.userProperty) {
      req._userProperty = options.userProperty;
    }
    
    var compat = (options.compat === undefined) ? true : options.compat;
    if (compat) {
      passport._userProperty = options.userProperty || 'user';
      req._passport = {};
      req._passport.instance = passport;
    }
    
    next();
  };
};

initialize(req, res, next){}라는 함수를 반환하네요. passport.initialize()를 하는 순간 initialize(req, res, next){}라는 형태의 함수가 반환되고, 우리는 이 함수를 app.use()의 인자로 전달하게 됩니다.


const express = require('express')
const app = express()

app.use((req, res, next) => {
  console.log('Time:', Date.now())
  next()
})

위 코드는 express에서 미들웨어를 사용하는 문법입니다.

기본적인 express의 request에 login / logout / isAuthenticated / isUnauthenticated 메서드를 추가하고 SessionManager도 붙여주고 있습니다.

한마디로, passport.initialize()는 요청 객체(req)에 인증 관련 메서드와 세션 매니저를 주입하여 Passport의 인증 흐름을 시작할 준비를 하는 과정이라고 할 수 있습니다.

3. passport.session() ✍️

passport는 내부적으로 Authenticator라고 했습니다. 이제 Authenticator.prototype.session()을 확인해야겠습니다.

Authenticator.prototype.session = function(options) {
  return this.authenticate('session', options);
};

Authenticator.prototype.session은 내부적으로 authenticate라는 함수를 실행하고 있네요. Authenticator.prototype.authenticate를 찾아가야겠습니다.

Authenticator.prototype.authenticate = function(strategy, options, callback) {
  return this._framework.authenticate(this, strategy, options, callback);
};

이번에는 _framework의 authenticate 함수를 실행하는 것을 확인할 수 있습니다.

passport/lib/middleware/authenticate.jsauthenticate(req, res, next){} 형식의 함수를 반환할 것으로 예상됩니다. 이 형태를 app.use()에 전달해야 express에 미들웨어로 등록이 될 테니까요.

  return function authenticate(req, res, next) {
    req.login = req.logIn = req.logIn || IncomingMessageExt.logIn;
    req.logout = req.logOut = req.logOut || IncomingMessageExt.logOut;
    req.isAuthenticated =
      req.isAuthenticated || IncomingMessageExt.isAuthenticated;
    req.isUnauthenticated =
      req.isUnauthenticated || IncomingMessageExt.isUnauthenticated;

    req._sessionManager = passport._sm;
    .
    .
    .
  };

initialize()와 마찬가지로, 기본적인 express의 request에 login / logout / isAuthenticated / isUnauthenticated 메서드를 추가하고 SessionManager도 붙여주고 있습니다.

4. req.login()과 serializeUser ✍️

(req as any).login()

이제서야 위 코드의 실행에 대해 이해할 수 있습니다.

passport/lib/http/request.js를 확인해 보죠.

req.login =
req.logIn = function(user, options, done) {
  if (typeof options == 'function') {
    done = options;
    options = {};
  }
  options = options || {};
  
  var property = this._userProperty || 'user';
  var session = (options.session === undefined) ? true : options.session;
  
  this[property] = user;
  if (session && this._sessionManager) {
    if (typeof done != 'function') { throw new Error('req#login requires a callback function'); }
    
    var self = this;
    this._sessionManager.logIn(this, user, options, function(err) {
      if (err) { self[property] = null; return done(err); }
      done();
    });
  } else {
    done && done();
  }
};

코드를 나눠서 보겠습니다.

if (typeof options == 'function') {
  done = options;
  options = {};
}
options = options || {};

이 코드는 req.login(user, done)처럼 options 없이 호출해도 동작하도록, 옵션과 콜백에 대한 유연성 처리를 구현한 것입니다.

var property = this._userProperty || 'user';

사용자 객체를 req.user에 저장하는 것이 기본이지만, 커스터마이징된 경우 this._userProperty를 사용할 수 있다는 점을 나타냅니다.

var session = (options.session === undefined) ? true : options.session;

별도로 명시된 내용이 없다면 기본값은 true로 설정합니다. 즉, 대부분의 경우 세션을 통해 로그인 정보를 저장하게 됩니다.

this[property] = user;

사용자 정보를 req에 할당합니다.

if (session && this._sessionManager) {

세션 사용 조건에 해당합니다. 세션이 true이고 sessionManager가 있다면 세션 저장을 진행합니다.

if (typeof done != 'function') {
  throw new Error('req#login requires a callback function');
}

세션 저장을 위해서는 done() 콜백이 필수적입니다. 세션은 비동기 방식으로 처리되기에, 완료 여부를 알려줄 콜백 함수가 없으면 안 됩니다.


var self = this;
this._sessionManager.logIn(this, user, options, function(err) {
  if (err) {
    self[property] = null;
    return done(err);
  }
  done();
});

실제 세션 저장은 this._sessionManager.logIn() 호출에 의해 진행됩니다.

SessionManager.prototype.logIn = function (req, user, options, cb) {
  if (typeof options == "function") {
    cb = options;
    options = {};
  }
  options = options || {};

  if (!req.session) {
    return cb(
      new Error(
        "Login sessions require session support. Did you forget to use `express-session` middleware?"
      )
    );
  }

  var self = this;
  var prevSession = req.session;

  req.session.regenerate(function (err) {
    if (err) {
      return cb(err);
    }

    self._serializeUser(user, req, function (err, obj) {
      if (err) {
        return cb(err);
      }
      if (options.keepSessionInfo) {
        merge(req.session, prevSession);
      }
      if (!req.session[self._key]) {
        req.session[self._key] = {};
      }

      req.session[self._key].user = obj;

      req.session.save(function (err) {
        if (err) {
          return cb(err);
        }
        cb();
      });
    });
  });
};

위 코드의 핵심 부분은 다음과 같습니다.

    self._serializeUser(user, req, function (err, obj) {
      if (err) {
        return cb(err);
      }
      if (options.keepSessionInfo) {
        merge(req.session, prevSession);
      }
      if (!req.session[self._key]) {
        req.session[self._key] = {};
      }

      req.session[self._key].user = obj;

      req.session.save(function (err) {
        if (err) {
          return cb(err);
        }
        cb();
      });
    });

이제, Pullim에 작성한 serializeUser가 어떻게 passport와 연결되는지를 알아보겠습니다.

5. @nestjs/passport와의 연결고리 ✍️

PassportSerializer로부터 상속받아서 AuthSerializer에 serializeUser() 로직을 작성했습니다.

  serializeUser(user: any, done: (err: any, id?: any) => void) {
    if (!user || !user.id) {
      return done(new Error('Invalid user object'));
    }

    const sessionData = {
      id: user.id,
      provider: user.provider,
    };

    done(null, sessionData);
  }

PassportSerializer는 추상 클래스로 구현되어 있습니다.

추상 클래스란, 직접 인스턴스화할 수 없고 상속해서 구현을 완성해야만 사용할 수 있는 클래스를 의미합니다. 그래서 serializeUser() 로직을 직접 구현한 것입니다.

import * as passport from 'passport';
export declare abstract class PassportSerializer {
    abstract serializeUser(user: any, done: Function): any;
    abstract deserializeUser(payload: any, done: Function): any;
    constructor();
    getPassportInstance(): passport.PassportStatic;
}

nestjs/passport/lib/passport/passport.serializer.ts 파일을 확인해 보니, serializeUser() 로직이 passportInstance에 연결되어 있는 모습을 볼 수 있었습니다.

import * as passport from 'passport';

export abstract class PassportSerializer {
  abstract serializeUser(user: any, done: Function): any;
  abstract deserializeUser(payload: any, done: Function): any;

  constructor() {
    const passportInstance = this.getPassportInstance();
    passportInstance.serializeUser((user, done) =>
      this.serializeUser(user, done)
    );
    passportInstance.deserializeUser((payload, done) =>
      this.deserializeUser(payload, done)
    );
  }

  getPassportInstance() {
    return passport;
  }
}

nestjs/passport/lib/passport/passport.strategy.ts에서 답을 찾을 수 있었습니다.

import * as passport from 'passport';

const passportInstance = this.getPassportInstance();

getPassportInstance() { return passport; }

getPassportInstance는 passport였습니다. passport는 내부적으로 Authenticator라고 했습니다.

따라서 passportInstance.serializeUser()는 다음 코드입니다.

Authenticator.prototype.serializeUser = function(fn, req, done) {
  if (typeof fn === 'function') {
    return this._serializers.push(fn);
  }
  .
  .
};

여기서 초반에 이야기했던 init()의 마지막 작업을 다시 살펴봐야 합니다.

this._sm = new SessionManager({ key: this._key }, this.serializeUser.bind(this));

특히 이 부분을 다시 봅시다.

this.serializeUser.bind(this));

위 코드는 passport(Authenticator)가 Authenticator.prototype.serializeUser() 메서드를 사용하겠다는 것과 같습니다.

Authenticator.prototype.serializeUser()는 위에서 확인했듯이 Authenticator의 _serializers에 우리가 작성한 serializeUser()를 넣어주는 역할을 하죠.


function SessionManager(options, serializeUser) {
  if (typeof options == 'function') {
    serializeUser = options;
    options = undefined;
  }
  options = options || {};
  
  this._key = options.key || 'passport';
  this._serializeUser = serializeUser;
}

이제 이 과정에서 SessionManager의 _serializeUser에 우리가 작성한 serializeUser()가 들어가게 됩니다. 결과적으로 req.login()을 실행하면 다음 코드가 동작합니다.

SessionManager.prototype.logIn = function(req, user, options, cb) {
  self._serializeUser(user, req){}
  }

6. next ✍️

  1. deserialize 과정도 정리하기
  2. express-session 간략하게나마 살펴보기
  3. passport 소스 코드 흐름을 도식화하기
profile
Write a little every day, without hope, without despair ✍️

0개의 댓글