passport-await
v0.1.0
Published
async/await for Passport without the four ways the usual wrapper breaks: an async verify that throws kills the process, a promise around authenticate() never settles on an OAuth redirect or pass(), and a rest-args verify adapter silently shifts passport-o
Maintainers
Readme
passport-await
The usual async wrapper around Passport breaks in four ways.
Passport's API is callbacks. Its most-reacted open issue, #536 "Add support promises flow", has 122 👍 and has been open since 2017. The thread, and StackOverflow, fill the gap with a few lines of wrapper. Measured against real passport 0.7.0, a real Express server and a real OAuth2 server:
1. async verify that throws connection dropped; server process exit code 1
2. promise around authenticate() OAuth2 login leg: 302 sent, promise still pending after 2s
3. strategy calls pass() promise rejects with `undefined`
4. (...args) verify adapter passport-oauth2 reads verify.length === 0 and
leaves out the token response (`params`, with id_token)Those are the first tests in this repository. They assert that the naive version is broken. If one starts failing, passport changed, and the matching part of this package may no longer be needed.
Why each one happens:
- Crash. Strategies call
verify(..., done)and ignore the return value. Anasyncverify that throws returns a rejected promise nobody handles. Since Node 15 that ends the process. An Express error handler doesn't help, because the error never reaches Express. - Hang.
passport.authenticate(name, callback)only callscallbackfor success, fail and error. Forredirectit writes the response itself, and forpassit callsnext(). So a promise that waits on the callback never settles on the first leg of every OAuth login. undefinedrejection. This is the same gap from the other side. Whennextis wired toreject,pass()looks like a failure with no reason.- Wrong arguments, no error. passport-oauth2 picks between
(accessToken, refreshToken, params, profile, done)and(accessToken, refreshToken, profile, done)based onverify.length. So do strategies that extend it — passport-google-oauth20, passport-github2 and passport-facebook all do. A(...args) => {}wrapper has length 0, so a verify written forparamsgetsprofilein that slot instead.
Use
npm install passport-awaitimport passport from 'passport';
import { Strategy as LocalStrategy } from 'passport-local';
import { authenticate, logIn, verify, reject } from 'passport-await';
passport.use(new LocalStrategy(verify(async (username, password) => {
const user = await db.users.find(username); // throwing here is a 500, not a crash
if (!user) return reject({ message: 'no such user' });
return (await argon2.verify(user.hash, password)) ? user : null;
})));
app.post('/login', async (req, res) => {
const outcome = await authenticate('local', req);
switch (outcome.type) {
case 'success': await logIn(req, outcome.user); return res.redirect('/');
case 'redirect': return res.redirect(outcome.status, outcome.url);
case 'fail': return res.status(outcome.failures[0]?.status ?? 401).json(outcome.failures);
case 'pass': return res.status(401).end();
}
});authenticate() resolves with what the strategy decided and writes nothing to
the response, so it works the same in an Express 5 handler, a GraphQL resolver, a
Fastify route or a NestJS guard. You send the redirect yourself.
| | |
|---|---|
| verify(fn, { arity? }) | Async verify for any strategy. Return a user to succeed, anything falsy to fail, reject(info) / accept(user, info) for details. A rejection becomes done(err). |
| authenticate(names, req, { strategyOptions?, authenticator?, signal? }) | Promise<{ type: 'success' \| 'fail' \| 'redirect' \| 'pass', ... }>. Errors reject. |
| logIn(req, user) / logOut(req) | Promise versions. Both regenerate the session, and a session store failure rejects. |
| serializeUser(fn) / deserializeUser(fn) | Async versions. A rejection becomes an error response. |
The arity rule, and where it still bites
The wrapper's length is fn.length + 1, so passport-oauth2 dispatches exactly
as it would for a hand-written callback. Function.length stops counting at the
first default or rest parameter:
async (accessToken, refreshToken, params, profile = {}) => ... // length 3, not 4In that case, pass verify(fn, { arity: 4 }). There is a test for this case,
because that's where a correct wrapper still goes wrong without an error.
Chains behave like passport's
authenticate(['jwt', 'api-key'], req) tries strategies in order. A fail moves
on to the next one. The first success, redirect, pass or error ends the chain.
If every strategy fails, the outcome lists each failure with its strategy name,
challenge and status. If a strategy decides twice, the first decision stands and
the second is ignored. That is a strategy bug, but it shouldn't change the result.
A strategy that never decides leaves the promise pending. Passport has the same
behaviour: the request hangs. Pass signal: AbortSignal.timeout(ms) to turn
that into an AuthenticationAborted rejection that names the strategy.
What it does not do
- No flash messages,
successRedirect,failureRedirect,assignPropertyorauthInfotransformation. Those are passport's middleware writing to the response or session. The point here is to hand you the decision, so you write those two lines yourself. - It does not replace
passport.initialize()/passport.session().logInneeds them mounted and says so if they aren't. - Serializer chains (
done('pass')) aren't supported by the asyncserializeUser/deserializeUser. Register those with passport directly. - It uses
authenticator._strategy(name), which is passport's internal lookup, not a public API. The peer range ispassport >= 0.6, and only 0.7.0 is tested. - Strategies tested: passport-local and passport-oauth2, plus small strategies that make one specific decision each. passport-jwt, OpenID Connect and SAML strategies are not tested. The only arity dispatch found in the strategies read was in passport-oauth2.
- The test authorization server issues tokens for any
code. These tests cover the full redirect → authorize → token → userinfo round trip. They don't cover whether a forged code is refused, because that is the server's job.
It is about 270 lines, so copying it is a reasonable choice. If you do, the tests are what to copy first.
Tests
npm install
npm run infra:up # a real OAuth2/OIDC server and Redis
npm test # 27 tests
npm run infra:down- The OAuth2 tests run the whole browser round trip against
navikt/mock-oauth2-server.
Despite the name, it is a real authorization server that signs real tokens.
The access token that comes out is checked against its
userinfoendpoint. - The crash tests run the server in a child process, so the exit code can be measured without taking the test runner down with it.
- The session tests use
connect-redis. The store-failure test uses a Redis ACL user that cannotDEL: loading and saving the session work, and only the destroy inside regenerate is refused. So the failure can only come fromlogIn.
Each guard was checked by removing it and running the suite. Without the
length fix, 4 tests fail. Without converting rejections to done(err), the
process-survives test fails. Without settling on redirect, 5 OAuth tests time
out. If fail ends the chain, 3 tests fail. Without the decide-once guard,
1 test fails. That last test was added after the first mutation run passed
without it: the test for a strategy that decides twice settled synchronously
before the second call, so it could not see the guard.
Built with Claude
Claude wrote most of this code. The design and the choice of problem are mine. The problem came from a scan for widely used packages whose users are asking, in the issue tracker, for something the maintainer hasn't answered. The four failures above were measured before any of the package was written.
Licence
MIT.
