@ukab/sanctuary
v0.2.8
Published
Tokenizer inspired by Laravel's Sanctum
Maintainers
Readme
Ukab Sanctuary
A tokenizer library that is compitable with both ESM and CommonJS. You can use this package with or without any framework.
Code Size
| ESM | CommonJS | Total | |:---:|:--------:|:-----:| | 70K | 75K | 145K |
Tokenizer Configuration
| Option | Required | Default | Description | |--------|----------|---------|-------------| | secret | Yes | - | Secret key for HMAC hashing (must not be 'secret') | | size | No | 40 | Token size in bytes | | algo | No | 'sha256' | Hash algorithm | | encoding | No | 'base64url' | Output encoding |
Usage
import { MongoClient, ObjectId } from 'mongodb';
import { Tokenizer, MongoRepo, TokenManager, MakeAccessToken } from '@ukab/sanctuary';
const client = new MongoClient('mongodb://localhost:27017/test');
// Only secret is required, other options have defaults
const tokenizer = new Tokenizer({
secret: process.env.TOKEN_SECRET || 'your-secure-secret-key'
});
const repo = new MongoRepo(
client.db().collection('access_tokens'),
{ from: (id) => new ObjectId(id) },
);
const manager = new TokenManager(repo, tokenizer);
async function test() {
// @returns error or token
const token = await manager.create({ tokenable_type: 'user', tokenable_id: '1', name: 'auth' });
if (token instanceof Error) throw token;
// Create access token string (id|plaintoken format required for verify)
const accessToken = MakeAccessToken(token.plaintoken, token.props.id);
// @returns null or token
const verified = await manager.verify(accessToken);
if (!verified) throw new Error('failed to verify token');
// @returns boolean
const used = await manager.used(token);
if (!used) throw new Error('failed to mark as used');
// @returns boolean
const expired = manager.expired(token, 1, 'day');
if (expired) throw new Error('token is expired')
client.close();
}
test();Manage expiry
// create with metadata
const token = await manager.create({
tokenable_type: 'user',
tokenable_id: '1',
name: 'auth',
metadata: { remember: true },
});
// expiry check
const expired = token.metadata.remember ? manager.expired(token, 1, 'year') : manager.expired(token, 1, 'day');
// will return Date instance, calculated based on given data and token "created_at" attribute
const expiryDate = manager.expiry(token, 1, 'year');You can use save extra data in "metadata" property
// create with metadata
const token = await manager.create({
tokenable_type: 'user',
tokenable_id: '1',
name: 'auth',
metadata: { expiry: Date.now() + 86400000 },
});MySQL Repo
const mysql = require('mysql2/promise');
const { Tokenizer, TokenManager, MakeAccessToken } = require('@ukab/sanctuary');
class MySQLRepo {
constructor(conn) {
this.conn = conn;
}
async create(token) {
const props = { ...token };
if (props.abilities) {
props.abilities = JSON.stringify(props.abilities);
}
const keys = Object.keys(token);
const placeholder = new Array(keys.length).fill('?');
const values = Object.values(token);
const [result] = await this.conn.execute(`insert into personal_tokens(${keys.join(',')}) values(${placeholder.join(',')})`, values);
return {
id: result.insertId.toString(),
tokenable_type: token.tokenable_type.toString(),
tokenable_id: token.tokenable_id.toString(),
name: token.name,
token: token.token,
abilities: token.abilities,
metadata: token.metadata,
last_used_at: token.last_used_at,
created_at: token.created_at,
updated_at: token.updated_at,
};
}
async find(props) {
let where = '';
const values = [];
if (props.id) {
where += 'id = ?'
values.push(props.id);
}
if (props.token) {
if (where != '') {
where += ' and ';
}
where += 'token = ?'
values.push(props.token);
}
const [rows] = await this.conn.execute(`select * from personal_tokens where ${where} limit 1`, values);
if (!rows.length) {
return null
}
const [model] = rows;
return {
id: model.id.toString(),
tokenable_type: model.tokenable_type.toString(),
tokenable_id: model.tokenable_id.toString(),
name: model.name,
token: model.token,
abilities: model.abilities ? JSON.parse(model.abilities) : [],
metadata: model.metadata ? JSON.parse(model.metadata) : {},
last_used_at: model.last_used_at,
created_at: model.created_at,
updated_at: model.updated_at,
};
}
async used(props) {
let where = '';
const values = [new Date()];
if (props.id) {
where += 'id = ?'
values.push(props.id);
}
if (props.token) {
if (where != '') {
where += ' and ';
}
where += 'token = ?'
values.push(props.token);
}
const [result] = await this.conn.execute(`update personal_tokens set last_used_at = ? where ${where}`, values);
if (!result.changedRows) {
return false;
}
return true;
}
async delete(props) {
let where = '';
const values = [new Date()];
if (props.id) {
where += 'id = ?'
values.push(props.id);
}
if (props.token) {
if (where != '') {
where += ' and ';
}
where += 'token = ?'
values.push(props.token);
}
const [result] = await this.conn.execute(`delete from personal_tokens where ${where}`, values);
if (!result.changedRows) {
return false;
}
return true;
}
}
async function test() {
const conn = await mysql.createConnection({
host: 'localhost',
user: 'test',
password: 'test',
database: 'test'
});
// Only secret is required
const tokenizer = new Tokenizer({
secret: process.env.TOKEN_SECRET || 'your-secure-secret-key'
});
const manager = new TokenManager(new MySQLRepo(conn), tokenizer);
// @returns error or token
const token = await manager.create({ tokenable_type: 'user', tokenable_id: '1', name: 'auth' });
if (token instanceof Error) throw token;
// Create access token string (id|plaintoken format required for verify)
const accessToken = MakeAccessToken(token.plaintoken, token.props.id);
// @returns null or token
const verified = await manager.verify(accessToken);
if (!verified) throw new Error('failed to verify token');
// @returns boolean
const used = await manager.used(token);
if (!used) throw new Error('failed to mark as used');
// @returns boolean
const expired = manager.expired(token, 1, 'day');
if (expired) throw new Error('token is expired')
console.log(token, typeof token.props.abilities);
conn.destroy();
}
test();Change Logs
v0.2.8
- BREAKING: Tokenizer now requires a config with secret (no insecure default)
- Fixed memory leak in MemRepo delete (orphaned index entries)
- Fixed Redis
used()returning false on subsequent calls - Fixed sanitizer error handling in TokenManager.create()
- Fixed type coercion issue in TokenParser for empty strings
- Added NoSQL injection protection in MongoRepo
- Added Redis key injection protection in RedisRepo
- Updated to Node.js 16+ and tap@18
v0.2.6
- typescript v5 support
