@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@johntalton/http-util",
3
- "version": "5.1.0",
3
+ "version": "5.1.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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
- export const SEPARATOR = {
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(SEPARATOR.PREFERENCE)
54
+ const preferences = new Map(header.split(PREFERENCE_SEPARATOR.PREFERENCE)
75
55
  .map(pref => {
76
- const [ kvp, ...params ] = pref.trim().split(SEPARATOR.PARAMS)
77
- const [ key, rawValue ] = kvp?.split(SEPARATOR.KVP) ?? []
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(SEPARATOR.PARAM_KVP)
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}${SEPARATOR.KVP}${value}` }
112
+ if(value !== undefined) { return `${key}${PREFERENCE_SEPARATOR.KVP}${value}` }
133
113
  return key
134
114
  }) ]
135
- .join(SEPARATOR.PREFERENCE)
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 ? `'${LIMIT_PARAMETERS.PARTITION_KEY}=${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 { IncomingHttpHeaders } from 'node:http2' */
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 {IncomingHttpHeaders} headers
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
- // WWW-Authenticate
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
+