# 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
```js
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
```js
// 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

```js
// create with metadata
const token = await manager.create({
  tokenable_type: 'user',
  tokenable_id: '1',
  name: 'auth',
  metadata: { expiry: Date.now() + 86400000 },
});
```

## MySQL Repo
```js
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