Passport strategy for authenticating with an NFT and a signed challenge for added security. Best used with dApps and a Node.js back-end.
This module lets you authenticate using an on-chain (defaults to Web3/EVM combo) NFT and an off-chain challenge record (usually using a database for long term storage). By using Passport, its very easy for anyone to integrate their own NFT authentication strategy. This module follows Connect-styled middleware, and can be used with Express.js or Koa.js and their variants.
$ npm install passport-nft --save
This module requires web3.js
to work, since it needs to access on-chain NFT data. Please refer to Web3.js documentation for the instance initialisation.
Please make sure your NFT has the following interface (minimal requirement):
ERC-721:
function balanceOf(address account) external view returns (uint256);
ERC-1155:
function balanceOf(address account, uint256 id) external view returns (uint256);
This Strategy will check the balance of the address with balanceOf
function after verifying the encrypted challenge. Make sure your wallet address owns the specified NFT to allow authentication. Pass in the address for your NFT in the Strategy
options.
For Chain ID, it is determined by the Web3
instance you pass to Strategy
initialisation. Please make sure it matches with the chain of your NFT deployment.
The NFT authentication strategy incorporates two elements, after which, the control is passed to your own verify
function to implement challenge string update or other features (such as issuing a Cookie containing access token; please note that this module does not set session):
- A prefixed challenge string that's signed by the caller address (with its original form obtainable with
getChallenge
option). This encrypted string is checked against theaddress
you pass in. If the framework detects an error during verification, it will automatically401
. - An NFT to check balance against, with your specified list of token IDs. With ERC-721,
balanceOf
is called withaddress
; with ERC-1155, an array oftokenIds
, and withaddress
. If theaddress
does not own the specifiednftAddress
NFT, it will result in a401
.
In other words, your verify
function will only be run if the challenge is verified (matches with address
), and the address
owns the NFT. Otherwise, failure to satisfy those requirements will make the Strategy to call fail
on Passport framework. (401
)
Example userInfo
passed to your verify function (more below):
(request, userInfo, done) => {
// e.g. NFTStrategy passes such userInfo to you
const {
address = "0x000...",
nftBalance = 3,
} = userInfo;
}
Example configuration:
passport.use(new NFTStrategy(
// {Object} Strategy Option
{
// @dev essential fields:
// @note getChallenge {Function} a function accepting `address`
// as parameter and should return a challenge string.
getChallenge: service.wallets.getChallengeByAddress,
// @note challenge {String} fallback string if `getChallenge`
// is not supplied or failed during execution. Default
// value below.
challenge: 'a_simple_challenge_from_api_server',
// @note tokenStandard {Number} token standard: 721 or 1155
tokenStandard: 1155,
// @note tokenIds {Array} is only required for ERC1155
// Will scan those IDs for balance:
tokenIds: [ 1, 2, 3 ],
nftAddress: '0x55d398326f99059fF775485246999027B3197955',
// @dev optional fields:
// @note addressField {String} header field name: address
addressField: 'wallet_address',
// @note challengeField {String} header field name: encrypted
// challenge
challengeField: 'encrypted_challenge',
// @dev key {String} prefix to the challenge before encryption.
// Default value below
key: 'nft:auth_',
// @note strategyIdentifier {String} Strategy identifier in Passport.
// Default value below
strategyIdentifier: 'nft',
// @note passReqToCallback {Boolean} Do I pass `req` to `verify` as
// its first param? Default value below
passReqToCallback: true,
// @note autoGrantUser {Boolean} Do I attach `userInfo` to `req`?
autoGrantUser: false,
// @note Reserved.
customTokenABI: [],
},
// {Web3} Web3.js Instance
// @note Reused for each call to save resource
new Web3('https://bsc-dataseed1.binance.org:443'),
// {Function} Verify function
// @note NFT ownership and signature is alreadt verified by this point
// Customise your own behaviour here
(request, userInfo, done) => {
const { method, url } = request;
const { Users } = app.model;
const { address } = userInfo;
// 1. Off-chain DB: Run findOrCreate on `users` table
// (Example uses Sequelize.js ORM v6)
// @note This behaviour can be done with the help of a
// getChallenge function passed in to provide a default
// challenge string if data isn't present in database.
Users.findOrCreate({
where: { address },
defaults: {
// Initialise challenge string with chance.js
challenge: (new Chance()).string({ alpha: true, numeric: true, }),
},
}).then(userInfo => {
// You may update the challenge string for this User
// And finally, call done() with first parameter being null
// (because first parameter is for error)
done(null, userInfo);
}).catch(dbErr => {
// You may return 4xx or 5xx here, depending on the framework
// of choice
});
}));
For a complete list of available options, please refer to lib/strategy.js
.
Use passport.authenticate()
, pass in "nft"
or your specified strategyIdentifier
to authenticate requests. Make sure your request headers contain content for both addressField
and challengeField
that matches specification.
As of web3.js
1.7.0, you may assemble the headers like so:
/**
* @function getLoginHeaders
* @description front-end function to get login headers
* @param {Web3} web3 This is the web3 instance to use
* @param {String} addressField Field name in header for address
* @param {String} challengeField Field name in header for signed challenge
*/
async function getLoginHeaders(
web3,
addressField = 'wallet_address',
challengeField = 'encrypted_challenge'
) {
// address: current wallet address
const address = await web3.eth.personal.getAccounts()[0] || '';
if (!(web3.utils.isAddress(address))) throw new Error('Connect wallet first');
// example API call for challenge string
const challengeStr = await fetch(new Request(`https://some.api.com/getChallenge/${address}`));
// web3 call to sign the challenge
// @note this step will show wallet dialogue
const signature = await web3.eth.personal.sign(
`NFT_AUTH_${challengeStr}`, // assuming NFT_AUTH_ matches your option.key
address,
null
);
// return the header object
const _header = {};
_header[addressField] = address;
_header[challengeField] = signature;
return _header;
};
/**
* @function loginByNft
* @description front-end function to call login/authentication
* @param {Web3} web3 This is the web3 instance to use
*/
async function loginByNft(web3) {
const headers = await getLoginHeaders(web3);
// This will be the call to your authenticate route,
// that will call passport.authenticate()
return fetch(new Request('https://some.api.com/login/nft', { headers }));
}
To use the authentication middleware, call it in this pattern (Connect-style middleware, e.g. Express
) on the server side:
// Authentication Route: NFT
app.get('/login/nft', passport.authenticate('nft'), function(req, res) {
// If you need redirection afterwards
res.redirect('/');
// If this is a separated API (from front-end):
res.send({
user: req.user, // assuming `req` has verified user object
});
});
Copyright (c) 2013~2022 yuuko.eth