@travelclw/proof-protocol 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (7) hide show
  1. package/README.md +81 -69
  2. package/browser.d.ts +42 -40
  3. package/browser.js +342 -306
  4. package/core.js +221 -219
  5. package/node.d.ts +80 -76
  6. package/package.json +1 -10
  7. package/server.js +316 -288
package/server.js CHANGED
@@ -1,288 +1,316 @@
1
- 'use strict'
2
-
3
- const { createHash, timingSafeEqual } = require('node:crypto')
4
- const { calculateJwkThumbprint, decodeProtectedHeader, importJWK, jwtVerify } = require('jose')
5
- const {
6
- DEFAULT_REQUEST_PROOF_TIME_TOLERANCE_SECONDS,
7
- RequestProofValidationError,
8
- canonicalizeBody,
9
- canonicalizeQuery,
10
- normalizePath,
11
- normalizeProofMode,
12
- } = require('./core')
13
-
14
- const REQUEST_PROOF_REASON_TEXT = Object.freeze({
15
- invalid_proof_key: '浏览器提交的 proof 公钥无效或缺失',
16
- missing_user: 'JWT 中缺少用户名,无法定位账号会话',
17
- missing_token: '请求缺少 Authorization Bearer token',
18
- missing_proof: '请求缺少浏览器 proof',
19
- missing_device_cookie: '请求缺少设备 Cookie,无法定位浏览器设备',
20
- missing_device_secret: '未配置设备会话摘要密钥,无法计算设备摘要',
21
- missing_binding: 'Redis 中不存在当前账号与设备 Cookie 的会话绑定',
22
- missing_session_context: '缺少当前 token 对应的设备会话上下文',
23
- missing_proof_binding: '设备会话尚未绑定浏览器 proof 公钥',
24
- token_mismatch: '当前 token 与设备会话绑定不一致',
25
- session_mismatch: 'JWT sessionId 与设备会话绑定不一致',
26
- session_id_mismatch: '登录会话 ID 与设备会话绑定不一致',
27
- missing_sso_source_binding: '一次性 SSO 凭证缺少可信源设备绑定',
28
- missing_sso_source_proof: '一次性 SSO 凭证缺少源浏览器 Proof 授权',
29
- sso_source_device_mismatch: '一次性 SSO 凭证与当前设备不匹配',
30
- proof_key_mismatch: '当前浏览器 proof 公钥与已绑定公钥不一致',
31
- proof_replayed: '请求 proof 的 jti 已使用,疑似重放请求',
32
- invalid_proof_format: '请求 proof 格式不是合法 JWT',
33
- invalid_proof_header: '请求 proof 头部算法或类型不符合要求',
34
- proof_expired: '请求 proof 时间与服务器时间偏差超过允许窗口',
35
- invalid_proof_jti: '请求 proof 缺少有效 jti',
36
- proof_method_mismatch: '请求 proof 绑定的 HTTP 方法不匹配',
37
- proof_path_mismatch: '请求 proof 绑定的接口路径不匹配',
38
- proof_token_mismatch: '请求 proof 绑定的 token 摘要不匹配',
39
- proof_query_mismatch: '请求 proof 绑定的查询参数不匹配',
40
- proof_body_mismatch: '请求 proof 绑定的请求内容不匹配',
41
- proof_request_id_mismatch: '请求 proof 绑定的请求 ID 不匹配',
42
- missing_proof_query_binding: '请求 proof 缺少查询参数绑定',
43
- missing_proof_body_binding: '请求 proof 缺少请求内容绑定',
44
- missing_proof_request_id_binding: '请求 proof 缺少请求 ID 绑定',
45
- invalid_proof_signature: '请求 proof 签名验签失败',
46
- proof_missing_or_invalid: '缺少 proof 或 proof 公钥无效',
47
- missing_bound_proof: '当前浏览器会话未绑定请求 proof 公钥',
48
- user_agent_changed: '请求 User-Agent 与登录绑定时不同',
49
- accept_language_changed: '请求 Accept-Language 与登录绑定时不同',
50
- verification_failed: '请求 proof 验证过程异常',
51
- })
52
-
53
- const getRequestProofReasonText = reason =>
54
- REQUEST_PROOF_REASON_TEXT[String(reason || '')] || '未知请求 Proof 校验失败原因'
55
-
56
- const safeEqual = (left, right) => {
57
- const leftBuffer = Buffer.from(String(left ?? ''))
58
- const rightBuffer = Buffer.from(String(right ?? ''))
59
- return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer)
60
- }
61
-
62
- const hashBindingValue = value => createHash('sha256').update(value).digest('base64url')
63
-
64
- const hasTransferredRequestBody = options => {
65
- const rawContentLength = Array.isArray(options?.contentLength)
66
- ? options.contentLength[0]
67
- : options?.contentLength
68
- const contentLengthText = String(rawContentLength ?? '').trim()
69
- if (contentLengthText) {
70
- const contentLength = Number(contentLengthText)
71
- if (Number.isFinite(contentLength) && contentLength >= 0) return contentLength > 0
72
- }
73
-
74
- const rawTransferEncoding = Array.isArray(options?.transferEncoding)
75
- ? options.transferEncoding[0]
76
- : options?.transferEncoding
77
- if (String(rawTransferEncoding ?? '').trim()) return true
78
-
79
- const body = options?.body
80
- if (body === undefined || body === null || body === '') return false
81
- if (
82
- typeof body === 'object' &&
83
- !Array.isArray(body) &&
84
- !Buffer.isBuffer(body) &&
85
- Object.keys(body).length === 0
86
- ) {
87
- return false
88
- }
89
- return true
90
- }
91
-
92
- const calculateRequestProofBindings = options => ({
93
- qsh: hashBindingValue(canonicalizeQuery(options?.path)),
94
- bth: hashBindingValue(canonicalizeBody(options?.body, options?.contentType, options?.hasBody)),
95
- rid: String(options?.requestId || '').trim(),
96
- })
97
-
98
- const normalizePublicKey = value => {
99
- const x = String(value?.x || '').trim()
100
- const y = String(value?.y || '').trim()
101
- const isCoordinate = coordinate =>
102
- coordinate.length >= 40 && coordinate.length <= 60 && /^[A-Za-z0-9_-]+$/.test(coordinate)
103
-
104
- if (value?.kty !== 'EC' || value?.crv !== 'P-256' || !isCoordinate(x) || !isCoordinate(y)) {
105
- return null
106
- }
107
- return { kty: 'EC', crv: 'P-256', x, y }
108
- }
109
-
110
- const normalizeRequestProofKey = async value => {
111
- const publicKey = normalizePublicKey(value?.publicKey)
112
- if (!publicKey) return null
113
-
114
- const keyId = await calculateJwkThumbprint(publicKey, 'sha256')
115
- const suppliedKeyId = String(value?.keyId || '').trim()
116
- if (suppliedKeyId && !safeEqual(suppliedKeyId, keyId)) return null
117
- return { keyId, publicKey }
118
- }
119
-
120
- const hashToken = token => createHash('sha256').update(token).digest('base64url')
121
-
122
- const getRequestProofKeyId = proofValue => {
123
- const proof = String(proofValue || '').trim()
124
- if (!proof) throw new RequestProofValidationError('missing_proof')
125
- if (proof.length < 64 || proof.length > 4096) {
126
- throw new RequestProofValidationError('invalid_proof_format')
127
- }
128
-
129
- let protectedHeader
130
- try {
131
- protectedHeader = decodeProtectedHeader(proof)
132
- } catch {
133
- throw new RequestProofValidationError('invalid_proof_header')
134
- }
135
- if (protectedHeader.typ !== 'dpop+jwt' || protectedHeader.alg !== 'ES256') {
136
- throw new RequestProofValidationError('invalid_proof_header')
137
- }
138
- const keyId = String(protectedHeader.kid || '').trim()
139
- if (!keyId) throw new RequestProofValidationError('invalid_proof_header')
140
- return keyId
141
- }
142
-
143
- const verifyRequestProof = async options => {
144
- const proof = String(options?.proof || '').trim()
145
- if (!proof) throw new RequestProofValidationError('missing_proof')
146
- if (proof.length < 64 || proof.length > 4096) {
147
- throw new RequestProofValidationError('invalid_proof_format')
148
- }
149
-
150
- const keyId = getRequestProofKeyId(proof)
151
- if (!safeEqual(keyId, options.keyId)) {
152
- throw new RequestProofValidationError('proof_key_mismatch')
153
- }
154
-
155
- try {
156
- const verificationKey = await importJWK(options.publicKey, 'ES256')
157
- const { payload } = await jwtVerify(proof, verificationKey, {
158
- algorithms: ['ES256'],
159
- audience: options.audience,
160
- typ: 'dpop+jwt',
161
- })
162
- const now = Math.floor(Date.now() / 1000)
163
- const timeToleranceSeconds =
164
- options.timeToleranceSeconds || DEFAULT_REQUEST_PROOF_TIME_TOLERANCE_SECONDS
165
- const issuedAt = Number(payload.iat)
166
- const timeWarning =
167
- !Number.isInteger(issuedAt) || Math.abs(issuedAt - now) > timeToleranceSeconds
168
- ? {
169
- reason: 'proof_expired',
170
- issuedAt: Number.isInteger(issuedAt) ? issuedAt : null,
171
- serverNow: now,
172
- diffSeconds: Number.isInteger(issuedAt) ? issuedAt - now : null,
173
- toleranceSeconds: timeToleranceSeconds,
174
- }
175
- : null
176
-
177
- const jti = String(payload.jti || '').trim()
178
- if (jti.length < 16 || jti.length > 200) {
179
- throw new RequestProofValidationError('invalid_proof_jti')
180
- }
181
- if (String(payload.htm || '').toUpperCase() !== String(options.method || '').toUpperCase()) {
182
- throw new RequestProofValidationError('proof_method_mismatch')
183
- }
184
- if (normalizePath(payload.htu) !== normalizePath(options.path)) {
185
- throw new RequestProofValidationError('proof_path_mismatch')
186
- }
187
- if (!safeEqual(String(payload.ath || ''), hashToken(options.token))) {
188
- throw new RequestProofValidationError('proof_token_mismatch')
189
- }
190
-
191
- const expectedBindings = calculateRequestProofBindings(options)
192
- const queryHash = String(payload.qsh || '').trim()
193
- if (options.requireRequestBindings && !queryHash) {
194
- throw new RequestProofValidationError('missing_proof_query_binding')
195
- }
196
- if (queryHash && !safeEqual(queryHash, expectedBindings.qsh)) {
197
- throw new RequestProofValidationError('proof_query_mismatch')
198
- }
199
-
200
- const bodyHash = String(payload.bth || '').trim()
201
- if (options.requireRequestBindings && !bodyHash) {
202
- throw new RequestProofValidationError('missing_proof_body_binding')
203
- }
204
- if (bodyHash && !safeEqual(bodyHash, expectedBindings.bth)) {
205
- throw new RequestProofValidationError('proof_body_mismatch')
206
- }
207
-
208
- const requestId = String(payload.rid || '').trim()
209
- if (options.requireRequestBindings && !requestId) {
210
- throw new RequestProofValidationError('missing_proof_request_id_binding')
211
- }
212
- if (requestId && !safeEqual(requestId, expectedBindings.rid)) {
213
- throw new RequestProofValidationError('proof_request_id_mismatch')
214
- }
215
- return { jti, timeWarning }
216
- } catch (error) {
217
- if (error instanceof RequestProofValidationError) throw error
218
- throw new RequestProofValidationError('invalid_proof_signature')
219
- }
220
- }
221
-
222
- const readOverride = (env, name, fallback) => {
223
- const value = env?.[name]
224
- return value !== undefined && String(value).trim() !== '' ? value : fallback
225
- }
226
-
227
- const integerSetting = (value, fallback, name, minimum, maximum) => {
228
- if (value === undefined || String(value).trim() === '') return fallback
229
- const parsed = Number(value)
230
- if (
231
- !Number.isInteger(parsed) ||
232
- parsed < minimum ||
233
- (maximum !== undefined && parsed > maximum)
234
- ) {
235
- const range = maximum === undefined ? `at least ${minimum}` : `between ${minimum} and ${maximum}`
236
- throw new Error(`${name} must be an integer ${range}`)
237
- }
238
- return parsed
239
- }
240
-
241
- const resolveProofServerConfig = options => {
242
- const env = options?.env ?? (typeof process !== 'undefined' ? process.env : {})
243
- const mode = normalizeProofMode(readOverride(env, 'AUTH_PROOF_MODE', options?.mode))
244
- const deviceSessionSecret = String(
245
- readOverride(env, 'AUTH_PROOF_DEVICE_SESSION_SECRET', options?.deviceSessionSecret) || '',
246
- ).trim()
247
- const deviceSessionTtlSeconds = integerSetting(
248
- readOverride(
249
- env,
250
- 'AUTH_PROOF_DEVICE_SESSION_TTL_SECONDS',
251
- options?.deviceSessionTtlSeconds,
252
- ),
253
- 12 * 60 * 60,
254
- 'AUTH_PROOF_DEVICE_SESSION_TTL_SECONDS',
255
- 1,
256
- )
257
- const timeToleranceSeconds = integerSetting(
258
- readOverride(env, 'AUTH_PROOF_TIME_TOLERANCE_SECONDS', options?.timeToleranceSeconds),
259
- DEFAULT_REQUEST_PROOF_TIME_TOLERANCE_SECONDS,
260
- 'AUTH_PROOF_TIME_TOLERANCE_SECONDS',
261
- 30,
262
- DEFAULT_REQUEST_PROOF_TIME_TOLERANCE_SECONDS,
263
- )
264
- if (mode !== 'off' && deviceSessionSecret.length < 32) {
265
- throw new Error(
266
- 'AUTH_PROOF_DEVICE_SESSION_SECRET is required when AUTH_PROOF_MODE is shadow or enforce and must contain at least 32 characters',
267
- )
268
- }
269
- return {
270
- mode,
271
- deviceSessionSecret,
272
- deviceSessionTtlSeconds,
273
- timeToleranceSeconds,
274
- }
275
- }
276
-
277
- module.exports = {
278
- REQUEST_PROOF_REASON_TEXT,
279
- RequestProofValidationError,
280
- calculateRequestProofBindings,
281
- getRequestProofKeyId,
282
- getRequestProofReasonText,
283
- hasTransferredRequestBody,
284
- normalizeProofMode,
285
- normalizeRequestProofKey,
286
- resolveProofServerConfig,
287
- verifyRequestProof,
288
- }
1
+ 'use strict'
2
+
3
+ const { createHash, timingSafeEqual } = require('node:crypto')
4
+ const { calculateJwkThumbprint, decodeProtectedHeader, importJWK, jwtVerify } = require('jose')
5
+ const {
6
+ DEFAULT_REQUEST_PROOF_TIME_TOLERANCE_SECONDS,
7
+ RequestProofValidationError,
8
+ canonicalizeBody,
9
+ canonicalizeQuery,
10
+ normalizePath,
11
+ normalizeProofMode,
12
+ } = require('./core')
13
+
14
+ const REQUEST_PROOF_REASON_TEXT = Object.freeze({
15
+ invalid_proof_key: '浏览器提交的 proof 公钥无效或缺失',
16
+ missing_user: 'JWT 中缺少用户名,无法定位账号会话',
17
+ missing_token: '请求缺少 Authorization Bearer token',
18
+ missing_proof: '请求缺少浏览器 proof',
19
+ missing_device_cookie: '请求缺少设备 Cookie,无法定位浏览器设备',
20
+ missing_device_secret: '未配置设备会话摘要密钥,无法计算设备摘要',
21
+ missing_binding: 'Redis 中不存在当前账号与设备 Cookie 的会话绑定',
22
+ missing_session_context: '缺少当前 token 对应的设备会话上下文',
23
+ missing_proof_binding: '设备会话尚未绑定浏览器 proof 公钥',
24
+ token_mismatch: '当前 token 与设备会话绑定不一致',
25
+ session_mismatch: 'JWT sessionId 与设备会话绑定不一致',
26
+ session_id_mismatch: '登录会话 ID 与设备会话绑定不一致',
27
+ missing_sso_source_binding: '一次性 SSO 凭证缺少可信源设备绑定',
28
+ missing_sso_source_proof: '一次性 SSO 凭证缺少源浏览器 Proof 授权',
29
+ sso_source_device_mismatch: '一次性 SSO 凭证与当前设备不匹配',
30
+ proof_key_mismatch: '当前浏览器 proof 公钥与已绑定公钥不一致',
31
+ proof_replayed: '请求 proof 的 jti 已使用,疑似重放请求',
32
+ invalid_proof_format: '请求 proof 格式不是合法 JWT',
33
+ invalid_proof_header: '请求 proof 头部算法或类型不符合要求',
34
+ proof_expired: '请求 proof 时间与服务器时间偏差超过允许窗口',
35
+ invalid_proof_jti: '请求 proof 缺少有效 jti',
36
+ proof_method_mismatch: '请求 proof 绑定的 HTTP 方法不匹配',
37
+ proof_path_mismatch: '请求 proof 绑定的接口路径不匹配',
38
+ proof_purpose_mismatch: '请求证明用途不匹配',
39
+ proof_session_mismatch: '请求证明与当前认证会话不匹配',
40
+ proof_target_mismatch: '一次性凭据的目标应用或密钥不匹配',
41
+ proof_token_mismatch: '请求 proof 绑定的 token 摘要不匹配',
42
+ proof_query_mismatch: '请求 proof 绑定的查询参数不匹配',
43
+ proof_body_mismatch: '请求 proof 绑定的请求内容不匹配',
44
+ proof_request_id_mismatch: '请求 proof 绑定的请求 ID 不匹配',
45
+ missing_proof_query_binding: '请求 proof 缺少查询参数绑定',
46
+ missing_proof_body_binding: '请求 proof 缺少请求内容绑定',
47
+ missing_proof_request_id_binding: '请求 proof 缺少请求 ID 绑定',
48
+ invalid_proof_signature: '请求 proof 签名验签失败',
49
+ proof_missing_or_invalid: '缺少 proof 或 proof 公钥无效',
50
+ missing_bound_proof: '当前浏览器会话未绑定请求 proof 公钥',
51
+ user_agent_changed: '请求 User-Agent 与登录绑定时不同',
52
+ accept_language_changed: '请求 Accept-Language 与登录绑定时不同',
53
+ verification_failed: '请求 proof 验证过程异常',
54
+ })
55
+
56
+ const getRequestProofReasonText = reason =>
57
+ REQUEST_PROOF_REASON_TEXT[String(reason || '')] || '未知请求 Proof 校验失败原因'
58
+
59
+ const safeEqual = (left, right) => {
60
+ const leftBuffer = Buffer.from(String(left ?? ''))
61
+ const rightBuffer = Buffer.from(String(right ?? ''))
62
+ return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer)
63
+ }
64
+
65
+ const hashBindingValue = value => createHash('sha256').update(value).digest('base64url')
66
+
67
+ const deriveProofSessionId = sessionId => {
68
+ if (typeof sessionId !== 'string' || !sessionId.trim()) {
69
+ throw new RequestProofValidationError('missing_session_context')
70
+ }
71
+ return hashBindingValue(`ctx-session-v2:${sessionId}`)
72
+ }
73
+
74
+ const hasTransferredRequestBody = options => {
75
+ const rawContentLength = Array.isArray(options?.contentLength)
76
+ ? options.contentLength[0]
77
+ : options?.contentLength
78
+ const contentLengthText = String(rawContentLength ?? '').trim()
79
+ if (contentLengthText) {
80
+ const contentLength = Number(contentLengthText)
81
+ if (Number.isFinite(contentLength) && contentLength >= 0) return contentLength > 0
82
+ }
83
+
84
+ const rawTransferEncoding = Array.isArray(options?.transferEncoding)
85
+ ? options.transferEncoding[0]
86
+ : options?.transferEncoding
87
+ if (String(rawTransferEncoding ?? '').trim()) return true
88
+
89
+ const body = options?.body
90
+ if (body === undefined || body === null || body === '') return false
91
+ if (
92
+ typeof body === 'object' &&
93
+ !Array.isArray(body) &&
94
+ !Buffer.isBuffer(body) &&
95
+ Object.keys(body).length === 0
96
+ ) {
97
+ return false
98
+ }
99
+ return true
100
+ }
101
+
102
+ const calculateRequestProofBindings = options => ({
103
+ qsh: hashBindingValue(canonicalizeQuery(options?.path)),
104
+ bth: hashBindingValue(canonicalizeBody(options?.body, options?.contentType, options?.hasBody)),
105
+ rid: String(options?.requestId || '').trim(),
106
+ })
107
+
108
+ const normalizePublicKey = value => {
109
+ const x = String(value?.x || '').trim()
110
+ const y = String(value?.y || '').trim()
111
+ const isCoordinate = coordinate =>
112
+ coordinate.length >= 40 && coordinate.length <= 60 && /^[A-Za-z0-9_-]+$/.test(coordinate)
113
+
114
+ if (value?.kty !== 'EC' || value?.crv !== 'P-256' || !isCoordinate(x) || !isCoordinate(y)) {
115
+ return null
116
+ }
117
+ return { kty: 'EC', crv: 'P-256', x, y }
118
+ }
119
+
120
+ const normalizeRequestProofKey = async value => {
121
+ const publicKey = normalizePublicKey(value?.publicKey)
122
+ if (!publicKey) return null
123
+
124
+ const keyId = await calculateJwkThumbprint(publicKey, 'sha256')
125
+ const suppliedKeyId = String(value?.keyId || '').trim()
126
+ if (suppliedKeyId && !safeEqual(suppliedKeyId, keyId)) return null
127
+ return { keyId, publicKey }
128
+ }
129
+
130
+ const hashToken = token => createHash('sha256').update(token).digest('base64url')
131
+
132
+ const getRequestProofKeyId = proofValue => {
133
+ const proof = String(proofValue || '').trim()
134
+ if (!proof) throw new RequestProofValidationError('missing_proof')
135
+ if (proof.length < 64 || proof.length > 4096) {
136
+ throw new RequestProofValidationError('invalid_proof_format')
137
+ }
138
+
139
+ let protectedHeader
140
+ try {
141
+ protectedHeader = decodeProtectedHeader(proof)
142
+ } catch {
143
+ throw new RequestProofValidationError('invalid_proof_header')
144
+ }
145
+ if (!['dpop+jwt', 'ctx-proof+jwt'].includes(protectedHeader.typ) || protectedHeader.alg !== 'ES256') {
146
+ throw new RequestProofValidationError('invalid_proof_header')
147
+ }
148
+ const keyId = String(protectedHeader.kid || '').trim()
149
+ if (!keyId) throw new RequestProofValidationError('invalid_proof_header')
150
+ return keyId
151
+ }
152
+
153
+ const verifyRequestProof = async options => {
154
+ const proof = String(options?.proof || '').trim()
155
+ if (!proof) throw new RequestProofValidationError('missing_proof')
156
+ if (proof.length < 64 || proof.length > 4096) {
157
+ throw new RequestProofValidationError('invalid_proof_format')
158
+ }
159
+
160
+ const keyId = getRequestProofKeyId(proof)
161
+ if (!safeEqual(keyId, options.keyId)) {
162
+ throw new RequestProofValidationError('proof_key_mismatch')
163
+ }
164
+
165
+ try {
166
+ const verificationKey = await importJWK(options.publicKey, 'ES256')
167
+ const { payload } = await jwtVerify(proof, verificationKey, {
168
+ algorithms: ['ES256'],
169
+ audience: options.audience,
170
+ typ: options.purpose ? 'ctx-proof+jwt' : 'dpop+jwt',
171
+ })
172
+ const now = Math.floor(Date.now() / 1000)
173
+ const timeToleranceSeconds =
174
+ options.timeToleranceSeconds || DEFAULT_REQUEST_PROOF_TIME_TOLERANCE_SECONDS
175
+ const issuedAt = Number(payload.iat)
176
+ const timeWarning =
177
+ !Number.isInteger(issuedAt) || Math.abs(issuedAt - now) > timeToleranceSeconds
178
+ ? {
179
+ reason: 'proof_expired',
180
+ issuedAt: Number.isInteger(issuedAt) ? issuedAt : null,
181
+ serverNow: now,
182
+ diffSeconds: Number.isInteger(issuedAt) ? issuedAt - now : null,
183
+ toleranceSeconds: timeToleranceSeconds,
184
+ }
185
+ : null
186
+
187
+ const jti = String(payload.jti || '').trim()
188
+ if (jti.length < 16 || jti.length > 200) {
189
+ throw new RequestProofValidationError('invalid_proof_jti')
190
+ }
191
+ if (String(payload.htm || '').toUpperCase() !== String(options.method || '').toUpperCase()) {
192
+ throw new RequestProofValidationError('proof_method_mismatch')
193
+ }
194
+ if (normalizePath(payload.htu) !== normalizePath(options.path)) {
195
+ throw new RequestProofValidationError('proof_path_mismatch')
196
+ }
197
+ if (options.purpose) {
198
+ if (payload.ver !== 2 || payload.purpose !== options.purpose) {
199
+ throw new RequestProofValidationError('proof_purpose_mismatch')
200
+ }
201
+ if (options.purpose === 'session') {
202
+ if (!safeEqual(payload.psid, deriveProofSessionId(options.sessionId))) {
203
+ throw new RequestProofValidationError('proof_session_mismatch')
204
+ }
205
+ } else if (options.purpose === 'ticket') {
206
+ if (!options.token || !safeEqual(payload.sth, hashToken(options.token))) {
207
+ throw new RequestProofValidationError('proof_token_mismatch')
208
+ }
209
+ } else {
210
+ throw new RequestProofValidationError('proof_purpose_mismatch')
211
+ }
212
+ return { jti, timeWarning }
213
+ }
214
+ if (!safeEqual(String(payload.ath || ''), hashToken(options.token))) {
215
+ throw new RequestProofValidationError('proof_token_mismatch')
216
+ }
217
+
218
+ const expectedBindings = calculateRequestProofBindings(options)
219
+ const queryHash = String(payload.qsh || '').trim()
220
+ if (options.requireRequestBindings && !queryHash) {
221
+ throw new RequestProofValidationError('missing_proof_query_binding')
222
+ }
223
+ if (queryHash && !safeEqual(queryHash, expectedBindings.qsh)) {
224
+ throw new RequestProofValidationError('proof_query_mismatch')
225
+ }
226
+
227
+ const bodyHash = String(payload.bth || '').trim()
228
+ if (options.requireRequestBindings && !bodyHash) {
229
+ throw new RequestProofValidationError('missing_proof_body_binding')
230
+ }
231
+ if (bodyHash && !safeEqual(bodyHash, expectedBindings.bth)) {
232
+ throw new RequestProofValidationError('proof_body_mismatch')
233
+ }
234
+
235
+ const requestId = String(payload.rid || '').trim()
236
+ if (options.requireRequestBindings && !requestId) {
237
+ throw new RequestProofValidationError('missing_proof_request_id_binding')
238
+ }
239
+ if (requestId && !safeEqual(requestId, expectedBindings.rid)) {
240
+ throw new RequestProofValidationError('proof_request_id_mismatch')
241
+ }
242
+ return { jti, timeWarning }
243
+ } catch (error) {
244
+ if (error instanceof RequestProofValidationError) throw error
245
+ throw new RequestProofValidationError('invalid_proof_signature')
246
+ }
247
+ }
248
+
249
+ const readOverride = (env, name, fallback) => {
250
+ const value = env?.[name]
251
+ return value !== undefined && String(value).trim() !== '' ? value : fallback
252
+ }
253
+
254
+ const integerSetting = (value, fallback, name, minimum, maximum) => {
255
+ if (value === undefined || String(value).trim() === '') return fallback
256
+ const parsed = Number(value)
257
+ if (
258
+ !Number.isInteger(parsed) ||
259
+ parsed < minimum ||
260
+ (maximum !== undefined && parsed > maximum)
261
+ ) {
262
+ const range = maximum === undefined ? `at least ${minimum}` : `between ${minimum} and ${maximum}`
263
+ throw new Error(`${name} must be an integer ${range}`)
264
+ }
265
+ return parsed
266
+ }
267
+
268
+ const resolveProofServerConfig = options => {
269
+ const env = options?.env ?? (typeof process !== 'undefined' ? process.env : {})
270
+ const mode = normalizeProofMode(readOverride(env, 'AUTH_PROOF_MODE', options?.mode))
271
+ const deviceSessionSecret = String(
272
+ readOverride(env, 'AUTH_PROOF_DEVICE_SESSION_SECRET', options?.deviceSessionSecret) || '',
273
+ ).trim()
274
+ const deviceSessionTtlSeconds = integerSetting(
275
+ readOverride(
276
+ env,
277
+ 'AUTH_PROOF_DEVICE_SESSION_TTL_SECONDS',
278
+ options?.deviceSessionTtlSeconds,
279
+ ),
280
+ 12 * 60 * 60,
281
+ 'AUTH_PROOF_DEVICE_SESSION_TTL_SECONDS',
282
+ 1,
283
+ )
284
+ const timeToleranceSeconds = integerSetting(
285
+ readOverride(env, 'AUTH_PROOF_TIME_TOLERANCE_SECONDS', options?.timeToleranceSeconds),
286
+ DEFAULT_REQUEST_PROOF_TIME_TOLERANCE_SECONDS,
287
+ 'AUTH_PROOF_TIME_TOLERANCE_SECONDS',
288
+ 30,
289
+ DEFAULT_REQUEST_PROOF_TIME_TOLERANCE_SECONDS,
290
+ )
291
+ if (mode !== 'off' && deviceSessionSecret.length < 32) {
292
+ throw new Error(
293
+ 'AUTH_PROOF_DEVICE_SESSION_SECRET is required when AUTH_PROOF_MODE is shadow or enforce and must contain at least 32 characters',
294
+ )
295
+ }
296
+ return {
297
+ mode,
298
+ deviceSessionSecret,
299
+ deviceSessionTtlSeconds,
300
+ timeToleranceSeconds,
301
+ }
302
+ }
303
+
304
+ module.exports = {
305
+ deriveProofSessionId,
306
+ REQUEST_PROOF_REASON_TEXT,
307
+ RequestProofValidationError,
308
+ calculateRequestProofBindings,
309
+ getRequestProofKeyId,
310
+ getRequestProofReasonText,
311
+ hasTransferredRequestBody,
312
+ normalizeProofMode,
313
+ normalizeRequestProofKey,
314
+ resolveProofServerConfig,
315
+ verifyRequestProof,
316
+ }