@t-bowersox/bouncer v0.2.0
bouncer
A tool for checking user authorization.
Installation
npm i @t-bowersox/bouncer
Changelog
See release notes.
Usage
Bouncer performs two primary sets of tasks:
- Issuing, revoking, & validating signed access tokens.
- Validating a user's attributes against a permissions ruleset.
Requirements
In order to create a Bouncer
instance, you must have the following:
- A private key string in PEM format.
- If your private key is encrypted by a passphrase, you must provide it as well.
- A public key string in PEM format.
- An object that implements the
TokenStore
interface.
Need help generating keys? This article provides a good explainer using OpenSSL.
Token signing
The private and public keys are necessary for signing and verifying access tokens. Under the hood, Bouncer uses the
Node.js crypto.createPrivateKey
and crypto.createPublicKey
methods to create a KeyObject
for each.
Those KeyObject
s are used with instances of
Node's Sign
and Verify
classes when signing and
verifying access tokens, respectively. The algorithm used for signatures is SHA256.
The Token
interface stores only a unique session ID, the user's ID, and an expiration timestamp. The session ID is
generated using
Node's crypto.randomUUID
, which
uses a cryptographic pseudorandom number generator.
Token stores
The TokenStore
interface requires an object to have the following methods, which are used for tracking revoked tokens:
storeTokenData
- This is called by Bouncer when creating a token to store its session ID, user ID, and timestamp values.addToDenyList
- This is called by Bouncer to add a session ID to the "Deny List", which is a list of revoked tokens.isOnDenyList
- This is called by Bouncer during token validation to see if a session ID has been revoked.
It is up to you to handle where to store the data, whether it's a key-value store, relational database, etc.
Authorization flow
Creating a Bouncer
instance
import { Bouncer } from "@t-bowersox/bouncer";
// This TokenStore implementation is just for demonstration
const tokenStore = {
async storeTokenData(
sessionId: string,
userId: string | number,
timestamp: number
): Promise<boolean> {
const database = new SomeDatabaseClass();
return await database.insert(sessionId, userId, timestamp);
},
async addToDenyList(sessionId: string, timestamp: number): Promise<boolean> {
const database = new SomeDatabaseClass();
return await database.insert(sessionId, timestamp);
},
async isOnDenyList(sessionId: string): Promise<boolean> {
const database = new SomeDatabaseClass();
return await database.get(sessionId);
},
};
// You'll most likely want to use env variables for the keys & passphrase
const bouncer = new Bouncer(
tokenStore,
"my-private-key-pem",
"my-public-key-pem",
"my-private-key-passphrase"
);
Creating an access token
An access token is a dot-separated string that includes:
- The base64 encoded token.
- The base64 encoded signature for that token.
For example: <base64-encoded-token>.<base64-encoded-signature>
. Being base64 encoded, it's suitable for returning to
the user in a secure, HTTP-only cookie.
To create a token, pass the user's ID and an expiration date (as a Date
object) to the createToken
method.
const token = bouncer.createToken(1, new Date("2022-02-28 00:00:00"));
Revoking an access token
In cases where you need to invalidate a session ID, you can call the revokeToken
method. This will add the session ID
to Bouncer's Deny List, which is a store of all revoked tokens. Bouncer checks this list during the token validation
process.
To add a token to the Deny List, pass its session ID to revokeToken
. You can retrieve these by querying the database
you're using for the TokenStore
's storeTokenData
function.
revokeToken
will return true
if it was successful, false
if not.
const revoked = bouncer.revokeToken("abcd-1234-efgh-5678");
Validating a token
To validate a token, simply pass it to the validateToken
method. Bouncer will return a boolean response based on the
following criteria:
- Is the token's signature verified? If so, then check:
- Has the token expired? If not, then check:
- Is the token on the Deny List? If not, return
true
.
Otherwise, Bouncer returns false
indicating the token is invalid. That's a signal to your app to deny access to that
user.
const validated = bouncer.validateToken(
"<base64-encoded-token>.<base64-encoded-signature>"
);
Validating a user
Bouncer's validateUser
method allows you to compare a user's attributes to a RuleSet
of validator functions. Each of
the functions you add to a RuleSet
take a value and, based on your criteria, returns a boolean: true
if
validated, false
if not.
Each RuleSet
keeps two internal sets of functions: one for synchronous functions and the other for async functions.
All functions in the sets must return true in order for Bouncer's validateUser
method to return true
. If just one
returns false
, then so will validateUser
.
type ValidationRule<T = any> = (userData: T) => boolean;
type AsyncValidationRule<T = any> = (userData: T) => Promise<boolean>;
To avoid unnecessary async calls, Bouncer will first evaluate the synchronous validators if present. Only if all of
those return true
will it proceed to the async validators.
You can create as many instances of the RuleSet
class as you need for your application. For example, you could
have RuleSet
s for different user types, different application routes, etc.
import { Bouncer, RuleSet } from "@t-bowersox/bouncer";
import { User } from "your-app-code";
const user: User = {
id: 1,
role: "regular",
permissions: { read: true, write: false },
};
const syncRuleExample = (user: User): boolean =>
user["permissions"]["read"] === true;
const asyncRuleExample = async (user: User): Promise<boolean> => {
const database = new MyDatabaseClass();
const result = await database.getSomeUserData(user.id);
return !!result;
};
let ruleSet = new RuleSet([syncRuleExample], [asyncRuleExample]);
// Or...
ruleSet = new RuleSet()
.addSyncRule(syncRuleExample)
.addAsyncRule(asyncRuleExample);
// Then...
const bouncer = new Bouncer(/*...*/);
const validUser = await bouncer.validateUser(user, ruleSet);
API
Class Bouncer
Creates a new Bouncer
instance.
class Bouncer {
constructor(
tokenStore: TokenStore,
privatePem: string,
publicPem: string,
passphrase?: string
) {}
}
Parameters:
tokenStore
: an object implementing theTokenStore
interface, used for adding to and checking the Deny List of tokens.privatePem
: a string containing a private key in PEM format, used for signing tokens.publicPem
: a string containing the correspondnig public key in PEM format, used for verifying tokens.passphrase
: if the private key was encrypted with a passphrase, pass it to this parameter.
Method createToken
Creates a new access token and stores its data in the TokenStore
.
class Bouncer {
async createToken(
userId: string | number,
expirationDate: Date
): Promise<string> {}
}
Parameters:
userId
: a string or a number that is the unique ID for your user.expirationDate
: aDate
object that indicates when the token should expire.
Returns:
- A promise resolving to a dot-separated string containing the base64-encoded token and its base64-encoded signature (
i.e.
<base64-encoded-token>.<base64-encoded-signature>
)
Method revokeToken
Adds a token to the TokenStore
's Deny List.
class Bouncer {
async revokeToken(unparsedToken: string): Promise<boolean> {}
}
Parameters:
unparsedToken
: the string returned fromcreateToken
.
Returns:
- A promise resolving to
true
if the token was successfully added to the Deny List,false
if not.
Method validateToken
Evaluates it a token is valid based on its signature, expiration date, and Deny List status.
class Bouncer {
async validateToken(unparsedToken: Base64String): Promise<boolean> {}
}
Parameters:
unparsedToken
: the string returned fromcreateToken
.
Returns:
- A promise resolving to
true
if the token is valid,false
if not.
Method validateUser
Evaluates a user's attributes against a RuleSet
to determine if the user meets the criteria for access.
class Bouncer {
async validateUser<T>(userData: T, rules: Ruleset): Promise<boolean> {}
}
Parameters:
userData
: the data to be evaluated by theRuleSet
.rules
: aRuleSet
instance containing one or moreValidationRule
and/orAsyncValidationRule
functions.
Returns:
- A promise resolving to
true
if all rules in the set returnedtrue
, otherwisefalse
.
Interface TokenStore
Contains async methods used by Bouncer to add revoked tokens to a database (referred to as the Deny List), as well as check for the existance of a token in that database.
interface TokenStore {
storeTokenData(
sessionId: string,
userId: string | number,
timestamp: number
): Promise<boolean>;
addToDenyList(sessionId: string, timestamp: number): Promise<boolean>;
isOnDenyList(sessionId: string): Promise<boolean>;
}
Method storeTokenData
Stores a token's session ID, user ID, and timestamp in a database.
interface TokenStore {
storeTokenData(
sessionId: string,
userId: string | number,
timestamp: number
): Promise<boolean>;
}
Parameters:
sessionId
: the token's session ID generated by Bouncer.userId
: the token's user ID passed to thecreateToken
method.timestamp
: the token'sexpirationTime
, based on the date passed to thecreateToken
method.
Returns:
- A promise resolving to
true
if the token data was saved successfully,false
if not.
Method addToDenyList
Stores the session ID and timestamp of a token in a database.
interface TokenStore {
addToDenyList(sessionId: string, timestamp: number): Promise<boolean>;
}
Parameters:
sessionId
- a string containing the revoked token's session ID.timestamp
- a number containing the current timestamp provided byDate.now()
.
Returns:
- A promise resolving to
true
if the token was successfully saved,false
if not.
Method isOnDenyList
Looks for the session ID of a token in a database.
interface TokenStore {
isOnDenyList(sessionId: string): Promise<boolean>;
}
Parameters:
sessionId
- a string containing the session ID to look for.
Returns:
- A promise resolving to
true
if the token was found,false
if not.
Interface Token
An access token. This is encoded to JSON then to base64 before it is returned by createToken
.
interface Token {
sessionId: string;
userId: string | number;
expirationTime: number;
}
Type Base64String
Indicates a string has been base64-encoded.
type Base64String = string;
Class RuleSet
class RuleSet {
constructor(
syncRules?: ValidationRule[],
asyncRules?: AsyncValidationRule[]
) {}
}
Parameters:
syncRules
: an optional array ofValidationRule
functions.asyncRules
: an optional array ofAsyncValidationRule
functions.
Method addSyncRule
Adds a synchronous ValidationRule
to the RuleSet
.
class RuleSet {
addSyncRule(rule: ValidationRule): Ruleset {}
}
Parameters:
rule
: theValidationRule
function to add.
Returns:
- The
RuleSet
instance so you can chain multiple calls.
Method addAsyncRule
Adds an AsyncValidationRule
to the RuleSet
.
class Ruleset {
addAsyncRule(rule: AsyncValidationRule): Ruleset {}
}
Parameters:
rule
: theAsyncValidationRule
function to add.
Returns:
- The
RuleSet
instance so you can chain multiple calls.
Method hasSyncRule
Checks if the RuleSet
contains a specific ValidationRule
.
class RuleSet {
hasSyncRule(rule: ValidationRule): boolean {}
}
Parameters:
rule
: theValidationRule
to look for.
Returns:
true
if present in theRuleSet
, otherwisefalse
.
Method hasAsyncRule
Checks if the RuleSet
contains a specific AsyncValidationRule
.
class RuleSet {
hasAsyncRule(rule: AsyncValidationRule): boolean {}
}
Parameters:
rule
: theAsyncValidationRule
to look for.
Returns:
true
if present in theRuleSet
, otherwisefalse
.
Method deleteSyncRule
Deletes a ValidationRule
from the RuleSet
.
class RuleSet {
deleteSyncRule(rule: ValidationRule): boolean {}
}
Parameters:
rule
: theValidationRule
to delete.
Returns:
true
if value was in the set,false
if not.
Method deleteAsyncRule
Deletes an AsyncValidationRule
from the RuleSet
.
class RuleSet {
deleteAsyncRule(rule: AsyncValidationRule): boolean {}
}
Parameters:
rule
: theAsyncValidationRule
to delete.
Returns:
true
if value was in the set,false
if not.
Method clearSyncRules
Deletes all ValidationRule
s from the RuleSet
.
class RuleSet {
clearSyncRules(): void {}
}
Method clearAsyncRules
Deletes all AsyncValidationRule
s from the RuleSet
.
class RuleSet {
clearAsyncRules(): void {}
}
Method evaluateSync
Compares user data against the RuleSet
's internal set of ValidationRule
s. This normally should not be called
directly. Instead, use Bouncer
's validateUser
method.
class RuleSet {
evaluateSync<T>(userData: T): boolean {}
}
Parameters:
userData
: the data to evaluate in theValidationRule
functions.
Returns:
true
if allValidationRule
s returned true, otherwisefalse
.
Method evaluateAsync
Compares user data against the RuleSet
's internal set of AsyncValidationRule
s. This normally should not be called
directly. Instead, use Bouncer
's validateUser
method.
class RuleSet {
evaluateAsync<T>(userData: T): Promise<boolean> {}
}
Parameters:
userData
: the data to evaluate in theValidationRule
functions.
Returns:
- A promise resolving to
true
if allAsyncValidationRule
s returned true, otherwisefalse
.
Type ValidationRule
A function that evaluates user data synchronously.
type ValidationRule<T = any> = (userData: T) => boolean;
Parameters:
userData
: the data to evaluate.
Returns:
true
if the data passed validation,false
if not.
Type AsyncValidationRule
A function that evaluates user data asynchronously.
type AsyncValidationRule<T = any> = (userData: T) => Promise<boolean>;
Parameters:
userData
: the data to evaluate.
Returns:
- A promise resolving to
true
if the data passed validation, orfalse
if not.
Contributing
This is primarily a package that I intend to reuse in my own projects. I've decided to open source it in case there are other folks who might also find it useful.
With that in mind, I only expect to make changes to Container that jibe with how I intend to use it myself.
But if you do have ideas or found bugs, please do file an issue and I'll gladly review it. 🙂