chore: add tests

This commit is contained in:
Chen Asraf
2022-11-29 14:17:36 +02:00
parent dec0a2d182
commit ce1421f9ea
10 changed files with 2052 additions and 143 deletions

View File

@@ -14,7 +14,7 @@ jobs:
node-version: '14.x'
# - run: cd doc-theme && yarn install && yarn build && rm -rf node_modules && cd ..
- run: yarn install --frozen-lockfile
- run: yarn docs:build
- run: yarn docs
- uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}

5
jest.config.js Normal file
View File

@@ -0,0 +1,5 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
};

View File

@@ -18,7 +18,8 @@
"scripts": {
"start": "concurrently \"tsc -w\" \"nodemon build/example/server.js\"",
"build": "tsc -p tsconfig.build.json && ts-node scripts/build.ts",
"docs:build": "typedoc --out docs src/index.ts"
"docs": "typedoc --out docs src/index.ts",
"test": "jest --coverage"
},
"dependencies": {
"hi-base32": "^0.5.1",
@@ -27,6 +28,7 @@
},
"devDependencies": {
"@types/express": "^4.17.14",
"@types/jest": "^29.2.3",
"@types/node": "^18.11.9",
"@types/qrcode": "^1.5.0",
"@types/totp-generator": "^0.0.4",
@@ -36,7 +38,9 @@
"dotenv": "^16.0.3",
"eslint": "^8.28.0",
"express": "^4.18.2",
"jest": "^29.3.1",
"nodemon": "^2.0.20",
"ts-jest": "^29.0.3",
"ts-node": "^10.9.1",
"typedoc": "^0.23.21",
"typescript": "^4.9.3"

View File

@@ -9,14 +9,9 @@ async function main() {
await fs.writeFile(path.join('build', 'package.json'), JSON.stringify(json, null, 2))
//
const viewDir = path.join('build', 'views')
const viewPath = path.join('src', 'views', 'get_token.html')
const viewOutPath = path.join('build', 'views', 'get_token.html')
await fs.mkdir(viewDir, { recursive: true })
console.log(`Copying ${viewPath}`)
const data = await fs.readFile(viewPath, 'utf-8')
await fs.writeFile(viewOutPath, data)
console.log('Copying .gitignore')
await copyFile('.gitignore')
console.log('Copying README.md')
await copyFile('README.md')
@@ -24,6 +19,18 @@ async function main() {
console.log('Copying LICENSE')
await copyFile('LICENSE')
const viewDir = path.join('build', 'views')
const viewPath = path.join('src', 'views', 'get_token.html')
const viewOutPath = path.join('build', 'views', 'get_token.html')
await fs.mkdir(viewDir, { recursive: true })
console.log(`Copying ${viewPath}`)
const data = await fs.readFile(viewPath, 'utf-8')
await fs.writeFile(viewOutPath, data)
console.log('Removing example')
fs.rm(path.join('build', 'example'), { recursive: true, force: true })
console.log('Done')
}

View File

@@ -1,20 +1,15 @@
import crypto from 'node:crypto'
import fs from 'fs/promises'
import path from 'path'
import { encode } from 'hi-base32'
import QR from 'qrcode'
import _totp from 'totp-generator'
import { NextFunction, Request, Response } from 'express'
import { AuthOptions, TotpApiOptions, TotpMiddlewares, TotpOptions, UserData } from './types'
import { AuthOptions, defaultOptions, TotpApiOptions, TotpMiddlewares, TotpOptions } from './types'
import { OTPError } from './error'
const defaultOptions: Omit<TotpOptions & TotpApiOptions<unknown>, 'issuer' | 'getUser'> = {
digits: 6,
period: 30,
algorithm: 'SHA-1',
getToken: (req) => req.query.token as string,
tokenFormOptions: {},
}
import {
_generateSecret,
_generateSecretQR,
_generateSecretURL,
_verifyToken,
_verifyUser,
} from './token'
export default function totp<U>(_options: TotpOptions & TotpApiOptions<U>): TotpMiddlewares<U> {
const options = {
@@ -50,7 +45,7 @@ export default function totp<U>(_options: TotpOptions & TotpApiOptions<U>): Totp
}
function generateNewSecret(): string {
return encode(crypto.randomBytes(32)).slice(0, 32)
return _generateSecret()
}
return {
@@ -63,98 +58,6 @@ export default function totp<U>(_options: TotpOptions & TotpApiOptions<U>): Totp
}
}
function _generateQR(uri: string, filename?: string): Promise<string> | Promise<void> {
if (!filename) {
return new Promise<string>((resolve, reject) => {
QR.toDataURL(uri, (err, uri) => {
if (err) {
reject(err)
return
}
resolve(uri)
})
})
}
return new Promise<void>((resolve, reject) => {
QR.toFile(filename, uri, (err) => {
if (err) {
reject(err)
return
}
resolve()
})
})
}
function _generateSecretQR<U>(
options: Required<TotpOptions & TotpApiOptions<U>>,
username: string,
secret: string,
filename: string | undefined,
) {
const uri = _generateSecretURL(options, username, secret)
return _generateQR(uri, filename) as Promise<never>
}
function _generateSecretURL<U>(
options: Required<TotpOptions & TotpApiOptions<U>>,
username: string,
secret: string,
) {
const uri = new URL('otpauth://totp/')
uri.username = options.issuer
uri.password = username
uri.searchParams.set('issuer', options.issuer)
uri.searchParams.set('account', username)
uri.searchParams.set('secret', secret)
if (defaultOptions.algorithm !== options.algorithm) {
uri.searchParams.set('algorithm', options.algorithm)
}
if (defaultOptions.digits !== options.digits) {
uri.searchParams.set('digits', options.digits.toString())
}
if (defaultOptions.period !== options.period) {
uri.searchParams.set('period', options.period.toString())
}
return uri.toString()
}
function _verifyToken<U>(
options: Required<TotpOptions & TotpApiOptions<U>>,
secret: string,
reqToken: string,
) {
const genToken = _totp(secret, options)
return genToken === reqToken
}
async function _verifyUser<U>(
options: Required<TotpOptions & TotpApiOptions<U>>,
req: Request,
userData: UserData<U> | undefined,
): Promise<U | undefined> {
if (!userData) {
return
}
const { user, secret } = userData
const token = await options.getToken(req)
if (token) {
if (!_verifyToken(options, secret, token)) {
return
}
return user
}
}
async function _authenticate<U>(
req: Request,
res: Response,

View File

@@ -3,7 +3,7 @@
*
* - `no_user` - No user was found for the request.
* - `no_token` - No token was found in the request.
* - `invalid_token` - The token was invalid.
* - `invalid_token` - The token was malformed or did not match the expected token.
*/
export type OTPErrorReason = 'invalid_token' | 'no_token' | 'no_user'

107
src/token.ts Normal file
View File

@@ -0,0 +1,107 @@
import crypto from 'node:crypto'
import { AllOptions, defaultOptions, UserData } from './types'
import QR from 'qrcode'
import _totp from 'totp-generator'
import { Request } from 'express'
import { encode } from 'hi-base32'
function _generateQR(uri: string, filename?: string): Promise<string> | Promise<void> {
if (!filename) {
return new Promise<string>((resolve, reject) => {
QR.toDataURL(uri, (err, uri) => {
if (err) {
reject(err)
return
}
resolve(uri)
})
})
}
return new Promise<void>((resolve, reject) => {
QR.toFile(filename, uri, (err) => {
if (err) {
reject(err)
return
}
resolve()
})
})
}
/** @hidden */
export function _generateSecretQR<U>(
options: Required<AllOptions<U>>,
username: string,
secret: string,
filename: string | undefined,
) {
const uri = _generateSecretURL(options, username, secret)
return _generateQR(uri, filename) as Promise<never>
}
/** @hidden */
export function _generateSecretURL<U>(
options: Required<Pick<AllOptions<U>, 'issuer' | 'algorithm' | 'digits' | 'period'>>,
username: string,
secret: string,
) {
const uri = new URL('otpauth://totp/')
uri.username = options.issuer
uri.password = username
uri.searchParams.set('issuer', options.issuer)
uri.searchParams.set('account', username)
uri.searchParams.set('secret', secret)
if (defaultOptions.algorithm !== options.algorithm) {
uri.searchParams.set('algorithm', options.algorithm)
}
if (defaultOptions.digits !== options.digits) {
uri.searchParams.set('digits', options.digits.toString())
}
if (defaultOptions.period !== options.period) {
uri.searchParams.set('period', options.period.toString())
}
return uri.toString()
}
/** @hidden */
export function _verifyToken<U>(
options: Required<AllOptions<U>>,
secret: string,
reqToken: string,
) {
const genToken = _totp(secret, options)
return genToken === reqToken
}
/** @hidden */
export async function _verifyUser<U>(
options: Required<AllOptions<U>>,
req: Request,
userData: UserData<U> | undefined,
): Promise<U | undefined> {
if (!userData) {
return
}
const { user, secret } = userData
const token = await options.getToken(req)
if (token) {
if (!_verifyToken(options, secret, token)) {
return
}
return user
}
}
/** @hidden */
export function _generateSecret(): string {
return encode(crypto.randomBytes(32)).slice(0, 32)
}

View File

@@ -11,6 +11,7 @@ declare global {
}
}
/** Options for TOTP generation */
export interface TotpOptions {
/** The issuer for your app (required) */
issuer: string
@@ -19,6 +20,8 @@ export interface TotpOptions {
/**
* The desired SHA variant (SHA-1, SHA-224, SHA-256, SHA-384, SHA-512,
* SHA3-224, SHA3-256, SHA3-384, SHA3-512, SHAKE128, or SHAKE256).
*
* Default is `SHA-1`.
*/
algorithm?:
| 'SHA-1'
@@ -47,8 +50,12 @@ export interface UserData<U> {
username: string
}
type PromiseOrValue<T> = T | Promise<T>
/**
* A promise of `T` or a value of `T`.
*/
export type PromiseOrValue<T> = T | Promise<T>
/** Options for API middleware flow */
export interface TotpApiOptions<U> {
/**
* If the return value is not `undefined`, it uses this function to verify and then inject the correct user into further
@@ -104,6 +111,9 @@ export interface TotpApiOptions<U> {
tokenFormOptions?: Partial<TokenFormOptions>
}
/** Combination of {@link TotpOptions} and {@link TotpApiOptions}. */
export type AllOptions<U> = TotpOptions & TotpApiOptions<U>
/**
* Options for generating the token form.
*/
@@ -129,10 +139,12 @@ export interface TotpMiddlewares<U> {
/**
* Middleware for authenticating a user, using their secret and the token provided in the request.
*
* @see {@link getUser | TotpOptions.getUser} in the options to control which user gets used for comparing the token to and later injected into
* @param {AuthOptions<U>} options Options for the middleware.
*
* @see {@link TotpApiOptions.getUser | TotpApiOptions.getUser} to control which user gets used for comparing the token to and later injected into
* further requests.
*
* @see {@link getToken | TotpOptions.getToken} in the options to control how the token is fetched in the request (query, headers, etc).
* @see {@link TotpApiOptions.getToken | TotpApiOptions.getToken} to control how the token is fetched in the request (query, headers, etc).
*/
authenticate(
options?: AuthOptions<U>,
@@ -208,3 +220,14 @@ export interface TotpMiddlewares<U> {
*/
verifyUser(req: Request): Promise<U | undefined>
}
export const defaultOptions: Omit<Required<AllOptions<unknown>>, 'issuer' | 'getUser'> = {
digits: 6,
period: 30,
algorithm: 'SHA-1',
getToken: (req) => req.query.token as string,
tokenFormOptions: {},
errorResponse: undefined as never,
tokenForm: false,
timestamp: undefined as never,
}

166
tests/token.test.ts Normal file
View File

@@ -0,0 +1,166 @@
import { Request } from 'express'
import { _generateSecret, _generateSecretURL, _verifyToken, _verifyUser } from '../src/token'
import { AllOptions, defaultOptions } from '../src/types'
import _totp from 'totp-generator'
describe('generateSecretURL', () => {
test('generates normal options', () => {
const secret = _generateSecret()
const options: Required<AllOptions<unknown>> = {
...defaultOptions,
issuer: 'issuer',
getUser: () => ({
user: { username: 'username' },
secret: secret,
username: 'username',
}),
}
expect(_generateSecretURL(options, 'username', secret)).toBe(
`otpauth://issuer:username@totp/?issuer=issuer&account=username&secret=${secret}`,
)
})
test('appends non-default options', () => {
const secret = _generateSecret()
const options: Required<AllOptions<unknown>> = {
...defaultOptions,
issuer: 'issuer',
getUser: () => ({
user: { username: 'username' },
secret: secret,
username: 'username',
}),
algorithm: 'SHA-256',
digits: 8,
}
expect(_generateSecretURL(options, 'username', secret)).toBe(
`otpauth://issuer:username@totp/?issuer=issuer&account=username&secret=${secret}&algorithm=SHA-256&digits=8`,
)
})
})
describe('generateSecret', () => {
test('length is 32', () => {
const secret = _generateSecret()
expect(secret).toHaveLength(32)
})
test('contains only valid chars', () => {
const secret = _generateSecret()
expect(secret).toMatch(/^[A-Z2-7=]+$/)
})
})
describe('verifyToken', () => {
test('verifies correct token', () => {
const secret = _generateSecret()
const token = _totp(secret, defaultOptions)
const options: Required<AllOptions<unknown>> = {
...defaultOptions,
issuer: 'issuer',
getUser: () => ({
user: { username: 'username' },
secret: secret,
username: 'username',
}),
}
expect(_verifyToken(options, secret, token)).toBeTruthy()
})
test('fails incorrect token', () => {
const secret = _generateSecret()
// const token = _totp(secret, defaultOptions)
const options: Required<AllOptions<unknown>> = {
...defaultOptions,
issuer: 'issuer',
getUser: () => ({
user: { username: 'username' },
secret: secret,
username: 'username',
}),
}
expect(_verifyToken(options, secret, '12345')).not.toBeTruthy()
})
})
describe('verifyUser', () => {
test('verifies correct user', async () => {
const secret = _generateSecret()
const token = _totp(secret, defaultOptions)
const userData = {
user: { username: 'username' },
secret: secret,
username: 'username',
}
const options: Required<AllOptions<unknown>> = {
...defaultOptions,
issuer: 'issuer',
getUser: () => userData,
}
expect(
await _verifyUser(
options,
{
query: {
token: token,
},
} as unknown as Request,
userData,
),
).toEqual(userData.user)
})
test('fails incorrect secret', async () => {
const secret = _generateSecret()
const token = _totp(secret, defaultOptions)
const userData = {
user: { username: 'username' },
secret: secret,
username: 'username',
}
const options: Required<AllOptions<unknown>> = {
...defaultOptions,
issuer: 'issuer',
getUser: () => userData,
}
expect(
await _verifyUser(
options,
{
query: {
token: token,
},
} as unknown as Request,
{ ...userData, secret: _generateSecret() },
),
).toBeUndefined()
})
test('fails incorrect token', async () => {
const secret = _generateSecret()
const userData = {
user: { username: 'username' },
secret: secret,
username: 'username',
}
const options: Required<AllOptions<unknown>> = {
...defaultOptions,
issuer: 'issuer',
getUser: () => userData,
}
expect(
await _verifyUser(
options,
{
query: {
token: '123456',
},
} as unknown as Request,
userData,
),
).toBeUndefined()
})
})

1742
yarn.lock

File diff suppressed because it is too large Load Diff