security.js
3.99 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
import { createHmac, randomBytes, scryptSync, timingSafeEqual } from 'node:crypto'
/**
* @typedef {Error & { code?: string }} CodedError
* @typedef {{ logger?: { info?: (...args: any[]) => void } }} DevEmailArgs
* @typedef {{ email?: string, to?: string, code?: string, purpose?: string }} EmailCodeArgs
*/
/**
* @param {string|Buffer} value
* @returns {string}
*/
function b64url(value) {
const buf = Buffer.isBuffer(value) ? value : Buffer.from(value, 'utf8')
return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
/**
* @param {string} value
* @returns {Buffer}
*/
function b64urlDecode(value) {
const pad = value.length % 4 === 0 ? '' : '='.repeat(4 - (value.length % 4))
return Buffer.from(value.replace(/-/g, '+').replace(/_/g, '/') + pad, 'base64')
}
/**
* @param {string} message
* @param {string} code
* @returns {CodedError}
*/
function codedError(message, code) {
const err = /** @type {CodedError} */ (new Error(message))
err.code = code
return err
}
export function createScryptHasher() {
const keyLength = 64
return {
/**
* @param {string} plain
* @returns {Promise<string>}
*/
async hash(plain) {
const salt = randomBytes(16)
const derived = scryptSync(String(plain), salt, keyLength)
return `scrypt$${salt.toString('hex')}$${derived.toString('hex')}`
},
/**
* @param {string} plain
* @param {string} stored
* @returns {Promise<boolean>}
*/
async verify(plain, stored) {
if (typeof stored !== 'string') return false
const parts = stored.split('$')
if (parts.length !== 3 || parts[0] !== 'scrypt') return false
const salt = Buffer.from(parts[1], 'hex')
const expected = Buffer.from(parts[2], 'hex')
const derived = scryptSync(String(plain), salt, expected.length)
return expected.length === derived.length && timingSafeEqual(expected, derived)
},
}
}
/**
* @param {{ secret: string }} args
* @returns {import('@drama/auth').TokenSigner}
*/
export function createHmacTokenSigner({ secret }) {
if (!secret || typeof secret !== 'string') throw new Error('createHmacTokenSigner: secret required')
const header = b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
/**
* @param {string} input
* @returns {string}
*/
function signature(input) {
return b64url(createHmac('sha256', secret).update(input).digest())
}
return {
/**
* @param {object} payload
* @param {{ ttlMs: number }} opts
* @returns {Promise<string>}
*/
async sign(payload, opts) {
const nowSec = Math.floor(Date.now() / 1000)
const claims = /** @type {Record<string, unknown>} */ ({ ...payload, iat: nowSec })
if (opts?.ttlMs && opts.ttlMs > 0) claims.exp = nowSec + Math.floor(opts.ttlMs / 1000)
const body = b64url(JSON.stringify(claims))
const input = `${header}.${body}`
return `${input}.${signature(input)}`
},
/**
* @param {string} token
* @returns {Promise<object>}
*/
async verify(token) {
if (typeof token !== 'string') throw codedError('token must be a string', 'TOKEN_INVALID')
const parts = token.split('.')
if (parts.length !== 3) throw codedError('malformed token', 'TOKEN_INVALID')
const [headerPart, bodyPart, sig] = parts
const expected = signature(`${headerPart}.${bodyPart}`)
const a = b64urlDecode(sig)
const b = b64urlDecode(expected)
if (a.length !== b.length || !timingSafeEqual(a, b)) throw codedError('bad signature', 'TOKEN_INVALID')
const claims = /** @type {Record<string, unknown>} */ (JSON.parse(b64urlDecode(bodyPart).toString('utf8')))
if (typeof claims.exp === 'number' && Math.floor(Date.now() / 1000) >= claims.exp) {
throw codedError('token expired', 'ERR_JWT_EXPIRED')
}
return claims
},
}
}
export function createDevOauthPort() {
return {
async exchange() {
throw new Error('[voko] oauth.exchange not configured: set GOOGLE_OAUTH_CLIENT_ID')
},
}
}