mirror of
https://github.com/chenasraf/express-otp.git
synced 2026-05-17 17:48:11 +00:00
chore: add tests
This commit is contained in:
2
.github/workflows/docs.yml
vendored
2
.github/workflows/docs.yml
vendored
@@ -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
5
jest.config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
/** @type {import('ts-jest').JestConfigWithTsJest} */
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
};
|
||||
@@ -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"
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
|
||||
115
src/auth.ts
115
src/auth.ts
@@ -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,
|
||||
|
||||
@@ -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
107
src/token.ts
Normal 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)
|
||||
}
|
||||
29
src/types.ts
29
src/types.ts
@@ -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
166
tests/token.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user