@johntalton/http-util 5.1.0 → 5.1.1
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.
- package/package.json +1 -1
- package/src/conditional.js +2 -17
- package/src/index.js +3 -0
- package/src/preference.js +9 -29
- package/src/quote.js +20 -0
- package/src/rate-limit.js +2 -2
- package/src/response/permanent-redirect.js +1 -1
- package/src/response/send-util.js +2 -2
- package/src/response/unauthorized.js +10 -3
- package/src/www-authenticate.js +132 -0
package/package.json
CHANGED
package/src/conditional.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isQuoted, stripQuotes } from './quote.js'
|
|
1
2
|
|
|
2
3
|
/**
|
|
3
4
|
* @typedef {Object} WeakEtagItem
|
|
@@ -65,23 +66,6 @@ export function isValidEtag(etag) {
|
|
|
65
66
|
return true
|
|
66
67
|
}
|
|
67
68
|
|
|
68
|
-
/**
|
|
69
|
-
* @param {string} etag
|
|
70
|
-
*/
|
|
71
|
-
export function stripQuotes(etag) {
|
|
72
|
-
return etag.substring(1, etag.length - 1)
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* @param {string} etag
|
|
77
|
-
*/
|
|
78
|
-
export function isQuoted(etag) {
|
|
79
|
-
if(etag.length <= 2) { return false }
|
|
80
|
-
if(!etag.startsWith(ETAG_QUOTE)) { return false }
|
|
81
|
-
if(!etag.endsWith(ETAG_QUOTE)) { return false }
|
|
82
|
-
return true
|
|
83
|
-
}
|
|
84
|
-
|
|
85
69
|
export class ETag {
|
|
86
70
|
/**
|
|
87
71
|
* @param {string} etag
|
|
@@ -121,6 +105,7 @@ export class ETag {
|
|
|
121
105
|
|
|
122
106
|
if(!isQuoted(quotedEtag)) { return undefined }
|
|
123
107
|
const etag = stripQuotes(quotedEtag)
|
|
108
|
+
if(etag === undefined) { return undefined }
|
|
124
109
|
if(!isValidEtag(etag)) { return undefined }
|
|
125
110
|
if(etag === CONDITION_ETAG_ANY) { return undefined }
|
|
126
111
|
|
package/src/index.js
CHANGED
|
@@ -5,12 +5,15 @@ export * from './accept-encoding.js'
|
|
|
5
5
|
export * from './accept-language.js'
|
|
6
6
|
export * from './accept-util.js'
|
|
7
7
|
export * from './cache-control.js'
|
|
8
|
+
export * from './clear-site-data.js'
|
|
8
9
|
export * from './conditional.js'
|
|
9
10
|
export * from './content-disposition.js'
|
|
10
11
|
export * from './content-range.js'
|
|
11
12
|
export * from './content-type.js'
|
|
12
13
|
export * from './forwarded.js'
|
|
13
14
|
export * from './multipart.js'
|
|
15
|
+
export * from './preference.js'
|
|
14
16
|
export * from './range.js'
|
|
15
17
|
export * from './rate-limit.js'
|
|
16
18
|
export * from './server-timing.js'
|
|
19
|
+
export * from './www-authenticate.js'
|
package/src/preference.js
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
// https://datatracker.ietf.org/doc/html/rfc7240
|
|
2
2
|
// https://www.rfc-editor.org/rfc/rfc7240#section-3
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
import { isQuoted, stripQuotes } from './quote.js'
|
|
5
|
+
|
|
6
|
+
export const PREFERENCE_SEPARATOR = {
|
|
5
7
|
PREFERENCE: ',',
|
|
6
8
|
PARAMS: ';',
|
|
7
9
|
PARAM_KVP: '=',
|
|
8
10
|
KVP: '='
|
|
9
11
|
}
|
|
10
12
|
|
|
11
|
-
export const QUOTE = '"'
|
|
12
|
-
|
|
13
13
|
export const DIRECTIVE_RESPOND_ASYNC = 'respond-async'
|
|
14
14
|
export const DIRECTIVE_WAIT = 'wait'
|
|
15
15
|
export const DIRECTIVE_HANDLING = 'handling'
|
|
@@ -23,26 +23,6 @@ export const DIRECTIVE_REPRESENTATION_MINIMAL = 'minimal'
|
|
|
23
23
|
export const DIRECTIVE_REPRESENTATION_HEADERS_ONLY = 'headers-only'
|
|
24
24
|
export const DIRECTIVE_REPRESENTATION_FULL = 'representation'
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* @param {string|undefined} value
|
|
29
|
-
*/
|
|
30
|
-
export function stripQuotes(value) {
|
|
31
|
-
if(value === undefined) { return undefined }
|
|
32
|
-
return value.substring(1, value.length - 1)
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* @param {string|undefined} value
|
|
37
|
-
*/
|
|
38
|
-
export function isQuoted(value) {
|
|
39
|
-
if(value === undefined) { return false }
|
|
40
|
-
if(value.length < 2) { return false }
|
|
41
|
-
if(!value.startsWith(QUOTE)) { return false }
|
|
42
|
-
if(!value.endsWith(QUOTE)) { return false }
|
|
43
|
-
return true
|
|
44
|
-
}
|
|
45
|
-
|
|
46
26
|
/**
|
|
47
27
|
* @typedef {Object} Preference
|
|
48
28
|
* @property {string|undefined} value
|
|
@@ -71,10 +51,10 @@ export class Preferences {
|
|
|
71
51
|
static parse(header) {
|
|
72
52
|
if(header === undefined) { return undefined }
|
|
73
53
|
|
|
74
|
-
const preferences = new Map(header.split(
|
|
54
|
+
const preferences = new Map(header.split(PREFERENCE_SEPARATOR.PREFERENCE)
|
|
75
55
|
.map(pref => {
|
|
76
|
-
const [ kvp, ...params ] = pref.trim().split(
|
|
77
|
-
const [ key, rawValue ] = kvp?.split(
|
|
56
|
+
const [ kvp, ...params ] = pref.trim().split(PREFERENCE_SEPARATOR.PARAMS)
|
|
57
|
+
const [ key, rawValue ] = kvp?.split(PREFERENCE_SEPARATOR.KVP) ?? []
|
|
78
58
|
|
|
79
59
|
if(key === undefined) { return {} }
|
|
80
60
|
const valueOrEmpty = isQuoted(rawValue) ? stripQuotes(rawValue) : rawValue
|
|
@@ -82,7 +62,7 @@ export class Preferences {
|
|
|
82
62
|
|
|
83
63
|
const parameters = new Map(params
|
|
84
64
|
.map(param => {
|
|
85
|
-
const [ pKey, rawPValue ] = param.split(
|
|
65
|
+
const [ pKey, rawPValue ] = param.split(PREFERENCE_SEPARATOR.PARAM_KVP)
|
|
86
66
|
if(pKey === undefined) { return {} }
|
|
87
67
|
const trimmedRawPValue = rawPValue?.trim()
|
|
88
68
|
const pValueOrEmpty = isQuoted(trimmedRawPValue) ? stripQuotes(trimmedRawPValue) : trimmedRawPValue
|
|
@@ -129,10 +109,10 @@ export class AppliedPreferences {
|
|
|
129
109
|
return [ ...preferences.entries()
|
|
130
110
|
.map(([ key, value ]) => {
|
|
131
111
|
// todo check if value should be quoted
|
|
132
|
-
if(value !== undefined) { return `${key}${
|
|
112
|
+
if(value !== undefined) { return `${key}${PREFERENCE_SEPARATOR.KVP}${value}` }
|
|
133
113
|
return key
|
|
134
114
|
}) ]
|
|
135
|
-
.join(
|
|
115
|
+
.join(PREFERENCE_SEPARATOR.PREFERENCE)
|
|
136
116
|
}
|
|
137
117
|
|
|
138
118
|
/**
|
package/src/quote.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export const QUOTE = '"'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @param {string|undefined} value
|
|
5
|
+
*/
|
|
6
|
+
export function stripQuotes(value) {
|
|
7
|
+
if(value === undefined) { return undefined }
|
|
8
|
+
return value.substring(1, value.length - 1)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {string|undefined} value
|
|
13
|
+
*/
|
|
14
|
+
export function isQuoted(value) {
|
|
15
|
+
if(value === undefined) { return false }
|
|
16
|
+
if(value.length < 2) { return false }
|
|
17
|
+
if(!value.startsWith(QUOTE)) { return false }
|
|
18
|
+
if(!value.endsWith(QUOTE)) { return false }
|
|
19
|
+
return true
|
|
20
|
+
}
|
package/src/rate-limit.js
CHANGED
|
@@ -8,7 +8,7 @@ export const HTTP_HEADER_RATE_LIMIT_POLICY = 'RateLimit-Policy'
|
|
|
8
8
|
* @property {string} name
|
|
9
9
|
* @property {number} remaining
|
|
10
10
|
* @property {number} resetSeconds
|
|
11
|
-
* @property {string} partitionKey
|
|
11
|
+
* @property {string|undefined} [partitionKey]
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
/**
|
|
@@ -50,7 +50,7 @@ export class RateLimit {
|
|
|
50
50
|
if(name === undefined || remaining === undefined) { return undefined }
|
|
51
51
|
|
|
52
52
|
const rs = resetSeconds ? `;${LIMIT_PARAMETERS.TIME_TILL_RESET_SECONDS}=${resetSeconds}` : ''
|
|
53
|
-
const pk = partitionKey ?
|
|
53
|
+
const pk = partitionKey ? `;${LIMIT_PARAMETERS.PARTITION_KEY}=${partitionKey}` : ''
|
|
54
54
|
return `"${name}";${LIMIT_PARAMETERS.REMAINING_QUOTA}=${remaining}${rs}${pk}`
|
|
55
55
|
}
|
|
56
56
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import http2 from 'node:http2'
|
|
2
|
+
|
|
2
3
|
import { send } from './send-util.js'
|
|
3
4
|
|
|
4
5
|
/** @import { ServerHttp2Stream } from 'node:http2' */
|
|
@@ -16,7 +17,6 @@ const { HTTP_STATUS_PERMANENT_REDIRECT } = http2.constants
|
|
|
16
17
|
* @param {Metadata} meta
|
|
17
18
|
*/
|
|
18
19
|
export function sendPermanentRedirect(stream, location, meta) {
|
|
19
|
-
throw new Error('unsupported')
|
|
20
20
|
send(stream, HTTP_STATUS_PERMANENT_REDIRECT, {
|
|
21
21
|
[HTTP2_HEADER_LOCATION]: location.href
|
|
22
22
|
}, [ HTTP2_HEADER_LOCATION ], undefined, undefined, meta)
|
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
} from './header-util.js'
|
|
21
21
|
|
|
22
22
|
/** @import { ServerHttp2Stream } from 'node:http2' */
|
|
23
|
-
/** @import {
|
|
23
|
+
/** @import { OutgoingHttpHeaders } from 'node:http2' */
|
|
24
24
|
/** @import { InputType } from 'node:zlib' */
|
|
25
25
|
/** @import { Metadata } from './defs.js' */
|
|
26
26
|
/** @import { EtagItem } from '../conditional.js' */
|
|
@@ -136,7 +136,7 @@ export function send_bytes(stream, status, contentType, obj, range, contentLengt
|
|
|
136
136
|
/**
|
|
137
137
|
* @param {ServerHttp2Stream} stream
|
|
138
138
|
* @param {number} status
|
|
139
|
-
* @param {
|
|
139
|
+
* @param {OutgoingHttpHeaders} headers
|
|
140
140
|
* @param {Array<string>} exposedHeaders
|
|
141
141
|
* @param {string|undefined} contentType
|
|
142
142
|
* @param {SendBody|undefined} body
|
|
@@ -1,18 +1,25 @@
|
|
|
1
1
|
import http2 from 'node:http2'
|
|
2
2
|
|
|
3
|
+
import { Challenge } from '../www-authenticate.js'
|
|
3
4
|
import { send } from './send-util.js'
|
|
4
5
|
|
|
5
6
|
/** @import { ServerHttp2Stream } from 'node:http2' */
|
|
6
7
|
/** @import { Metadata } from './defs.js' */
|
|
8
|
+
/** @import { ChallengeItem } from '../www-authenticate.js' */
|
|
9
|
+
|
|
10
|
+
const {
|
|
11
|
+
HTTP2_HEADER_WWW_AUTHENTICATE
|
|
12
|
+
} = http2.constants
|
|
7
13
|
|
|
8
14
|
const { HTTP_STATUS_UNAUTHORIZED } = http2.constants
|
|
9
15
|
|
|
10
16
|
/**
|
|
11
17
|
* @param {ServerHttp2Stream} stream
|
|
18
|
+
* @param {Array<ChallengeItem>|undefined} challenge
|
|
12
19
|
* @param {Metadata} meta
|
|
13
20
|
*/
|
|
14
|
-
export function sendUnauthorized(stream, meta) {
|
|
21
|
+
export function sendUnauthorized(stream, challenge, meta) {
|
|
15
22
|
send(stream, HTTP_STATUS_UNAUTHORIZED, {
|
|
16
|
-
|
|
17
|
-
}, [], undefined, undefined, meta)
|
|
23
|
+
[HTTP2_HEADER_WWW_AUTHENTICATE]: challenge?.map(Challenge.encode),
|
|
24
|
+
}, [ HTTP2_HEADER_WWW_AUTHENTICATE ], undefined, undefined, meta)
|
|
18
25
|
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {Object} ChallengeItem
|
|
3
|
+
* @property {string} scheme
|
|
4
|
+
* @property {Map<string, string|undefined>} [parameters]
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** @typedef {'invalid_request'|'invalid_token'|'insufficient_scope'} BearerErrorCode */
|
|
8
|
+
|
|
9
|
+
export const PARAMETERS_THAT_NEED_QUOTES = [
|
|
10
|
+
'realm',
|
|
11
|
+
'charset',
|
|
12
|
+
'domain',
|
|
13
|
+
'nonce',
|
|
14
|
+
'opaque',
|
|
15
|
+
'qop',
|
|
16
|
+
'uri',
|
|
17
|
+
'cnonce',
|
|
18
|
+
'response',
|
|
19
|
+
// 'max-age', // todo should this be included
|
|
20
|
+
'challenge',
|
|
21
|
+
'scope',
|
|
22
|
+
'error',
|
|
23
|
+
'error_description',
|
|
24
|
+
'error_uri'
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {string} paramName
|
|
29
|
+
*/
|
|
30
|
+
export function paramNeedQuotes(paramName) {
|
|
31
|
+
return PARAMETERS_THAT_NEED_QUOTES.includes(paramName.toLowerCase())
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
export class Challenge {
|
|
36
|
+
/**
|
|
37
|
+
* @param {string} realm
|
|
38
|
+
* @param {string} [charset='utf-8']
|
|
39
|
+
* @returns {ChallengeItem}
|
|
40
|
+
*/
|
|
41
|
+
static basic(realm, charset = 'utf-8') {
|
|
42
|
+
const parameters = new Map([ [ 'realm', realm ] ])
|
|
43
|
+
if(charset !== undefined) { parameters.set('charset', charset) }
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
scheme: 'Basic',
|
|
47
|
+
parameters
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @param {string|undefined} [realm]
|
|
53
|
+
* @param {string|undefined} [scope]
|
|
54
|
+
* @param {BearerErrorCode} [error]
|
|
55
|
+
* @param {string} [errorDescription]
|
|
56
|
+
* @param {string} [errorUri]
|
|
57
|
+
* @returns {ChallengeItem}
|
|
58
|
+
*/
|
|
59
|
+
static bearer(realm, scope, error, errorDescription, errorUri) {
|
|
60
|
+
const parameters = new Map()
|
|
61
|
+
if(realm !== undefined) { parameters.set('realm', realm) }
|
|
62
|
+
if(scope !== undefined) { parameters.set('scope', scope) }
|
|
63
|
+
if(error !== undefined) { parameters.set('error', error) }
|
|
64
|
+
if(errorDescription !== undefined) { parameters.set('error_description', errorDescription) }
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
scheme: 'Bearer',
|
|
68
|
+
parameters
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @param {string} algorithm
|
|
74
|
+
* @param {string} [realm]
|
|
75
|
+
* @returns {ChallengeItem}
|
|
76
|
+
*/
|
|
77
|
+
static digest(algorithm, realm) {
|
|
78
|
+
return {
|
|
79
|
+
scheme: 'Digest'
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* @returns {ChallengeItem}
|
|
85
|
+
*/
|
|
86
|
+
static hoba() {
|
|
87
|
+
return {
|
|
88
|
+
scheme: 'HOBA'
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* @param {ChallengeItem} challenge
|
|
94
|
+
*/
|
|
95
|
+
static encode(challenge) {
|
|
96
|
+
const parameters = challenge.parameters?.entries().map(([ key, value ]) => {
|
|
97
|
+
if(value === undefined) { return key }
|
|
98
|
+
if(paramNeedQuotes(key)) { return `${key}="${value}"` }
|
|
99
|
+
return `${key}=${value}`
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
const params = parameters !== undefined ? [ ...parameters ].join(',') : ''
|
|
103
|
+
|
|
104
|
+
return `${challenge.scheme} ${params}`
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// console.log(Challenge.encode(Challenge.basic('Dev')))
|
|
109
|
+
// Basic realm="Dev", charset="UTF-8"
|
|
110
|
+
|
|
111
|
+
// HOBA max-age="180", challenge="16:MTEyMzEyMzEyMw==1:028:https://www.example.com:8080:3:MTI48:NjgxNDdjOTctNDYxYi00MzEwLWJlOWItNGM3MDcyMzdhYjUz"
|
|
112
|
+
|
|
113
|
+
// Digest username="Mufasa",
|
|
114
|
+
// realm="http-auth@example.org",
|
|
115
|
+
// uri="/dir/index.html",
|
|
116
|
+
// algorithm=SHA-256,
|
|
117
|
+
// nonce="7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v",
|
|
118
|
+
// nc=00000001,
|
|
119
|
+
// cnonce="f2/wE4q74E6zIJEtWaHKaf5wv/H5QzzpXusqGemxURZJ",
|
|
120
|
+
// qop=auth,
|
|
121
|
+
// response="753927fa0e85d155564e2e272a28d1802ca10daf449
|
|
122
|
+
// 6794697cf8db5856cb6c1",
|
|
123
|
+
// opaque="FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS"
|
|
124
|
+
|
|
125
|
+
// console.log(Challenge.encode(Challenge.bearer(undefined, 'openid profile email')))
|
|
126
|
+
// Bearer scope="openid profile email"
|
|
127
|
+
// Bearer scope="urn:example:channel=HBO&urn:example:rating=G,PG-13"
|
|
128
|
+
|
|
129
|
+
// WWW-Authenticate: Bearer realm="example",
|
|
130
|
+
// error="invalid_token",
|
|
131
|
+
// error_description="The access token expired"
|
|
132
|
+
|