@atproto/lex-client 0.0.10 → 0.0.12
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/CHANGELOG.md +32 -0
- package/dist/agent.d.ts +72 -0
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +46 -1
- package/dist/agent.js.map +1 -1
- package/dist/client.d.ts +442 -46
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +145 -1
- package/dist/client.js.map +1 -1
- package/dist/errors.d.ts +202 -48
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +208 -65
- package/dist/errors.js.map +1 -1
- package/dist/lexicons/com/atproto/repo/createRecord.defs.d.ts +20 -20
- package/dist/lexicons/com/atproto/repo/deleteRecord.defs.d.ts +12 -12
- package/dist/lexicons/com/atproto/repo/getRecord.defs.d.ts +6 -6
- package/dist/lexicons/com/atproto/repo/listRecords.defs.d.ts +6 -6
- package/dist/lexicons/com/atproto/repo/putRecord.defs.d.ts +22 -22
- package/dist/lexicons/com/atproto/repo/uploadBlob.defs.d.ts +2 -2
- package/dist/response.d.ts +17 -6
- package/dist/response.d.ts.map +1 -1
- package/dist/response.js +45 -32
- package/dist/response.js.map +1 -1
- package/dist/types.d.ts +51 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/util.d.ts +40 -5
- package/dist/util.d.ts.map +1 -1
- package/dist/util.js +22 -0
- package/dist/util.js.map +1 -1
- package/dist/www-authenticate.d.ts +35 -0
- package/dist/www-authenticate.d.ts.map +1 -0
- package/dist/www-authenticate.js +57 -0
- package/dist/www-authenticate.js.map +1 -0
- package/dist/xrpc.d.ts +82 -10
- package/dist/xrpc.d.ts.map +1 -1
- package/dist/xrpc.js +15 -28
- package/dist/xrpc.js.map +1 -1
- package/package.json +7 -7
- package/src/agent.ts +101 -1
- package/src/client.ts +428 -15
- package/src/errors.ts +308 -120
- package/src/response.ts +68 -63
- package/src/types.ts +52 -0
- package/src/util.ts +50 -5
- package/src/www-authenticate.test.ts +227 -0
- package/src/www-authenticate.ts +101 -0
- package/src/xrpc.ts +100 -53
package/src/response.ts
CHANGED
|
@@ -1,32 +1,29 @@
|
|
|
1
1
|
import { lexParse } from '@atproto/lex-json'
|
|
2
2
|
import {
|
|
3
|
-
InferMethodOutputBody,
|
|
4
3
|
InferMethodOutputEncoding,
|
|
5
4
|
Procedure,
|
|
6
5
|
Query,
|
|
7
6
|
ResultSuccess,
|
|
8
7
|
} from '@atproto/lex-schema'
|
|
9
8
|
import {
|
|
9
|
+
XrpcAuthenticationError,
|
|
10
10
|
XrpcResponseError,
|
|
11
11
|
XrpcUpstreamError,
|
|
12
12
|
isXrpcErrorPayload,
|
|
13
13
|
} from './errors.js'
|
|
14
|
-
import {
|
|
14
|
+
import { XrpcResponseBody, XrpcResponsePayload } from './util.js'
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
const CONTENT_TYPE_BINARY = 'application/octet-stream'
|
|
17
|
+
const CONTENT_TYPE_JSON = 'application/json'
|
|
18
18
|
|
|
19
|
-
export type
|
|
20
|
-
InferMethodOutputEncoding<M> extends infer E extends string
|
|
21
|
-
? XrpcPayload<XrpcResponseBody<M>, E>
|
|
22
|
-
: null
|
|
19
|
+
export type { XrpcResponseBody, XrpcResponsePayload }
|
|
23
20
|
|
|
24
21
|
/**
|
|
25
22
|
* Small container for XRPC response data.
|
|
26
23
|
*
|
|
27
24
|
* @implements {ResultSuccess<XrpcResponse<M>>} for convenience in result handling contexts.
|
|
28
25
|
*/
|
|
29
|
-
export class XrpcResponse<
|
|
26
|
+
export class XrpcResponse<M extends Procedure | Query>
|
|
30
27
|
implements ResultSuccess<XrpcResponse<M>>
|
|
31
28
|
{
|
|
32
29
|
/** @see {@link ResultSuccess.success} */
|
|
@@ -49,13 +46,24 @@ export class XrpcResponse<const M extends Procedure | Query>
|
|
|
49
46
|
* in binary form {@link Uint8Array} (`false`).
|
|
50
47
|
*/
|
|
51
48
|
get isParsed() {
|
|
52
|
-
return this.encoding ===
|
|
49
|
+
return this.method.output.encoding === CONTENT_TYPE_JSON
|
|
53
50
|
}
|
|
54
51
|
|
|
52
|
+
/**
|
|
53
|
+
* The Content-Type encoding of the response (e.g., 'application/json').
|
|
54
|
+
* Returns `undefined` if the response has no body.
|
|
55
|
+
*/
|
|
55
56
|
get encoding() {
|
|
56
57
|
return this.payload?.encoding as InferMethodOutputEncoding<M>
|
|
57
58
|
}
|
|
58
59
|
|
|
60
|
+
/**
|
|
61
|
+
* The parsed response body.
|
|
62
|
+
*
|
|
63
|
+
* For 'application/json' responses, this is the parsed and validated LexValue.
|
|
64
|
+
* For binary responses, this is a Uint8Array.
|
|
65
|
+
* Returns `undefined` if the response has no body.
|
|
66
|
+
*/
|
|
59
67
|
get body() {
|
|
60
68
|
return this.payload?.body as XrpcResponseBody<M>
|
|
61
69
|
}
|
|
@@ -63,7 +71,8 @@ export class XrpcResponse<const M extends Procedure | Query>
|
|
|
63
71
|
/**
|
|
64
72
|
* @throws {XrpcResponseError} in case of (valid) XRPC error responses. Use
|
|
65
73
|
* {@link XrpcResponseError.matchesSchema} to narrow the error type based on
|
|
66
|
-
* the method's declared error schema.
|
|
74
|
+
* the method's declared error schema. This can be narrowed further as a
|
|
75
|
+
* {@link XrpcAuthenticationError} if the error is an authentication error.
|
|
67
76
|
* @throws {XrpcUpstreamError} when the response is not a valid XRPC
|
|
68
77
|
* response, or if the response does not conform to the method's schema.
|
|
69
78
|
*/
|
|
@@ -79,39 +88,49 @@ export class XrpcResponse<const M extends Procedure | Query>
|
|
|
79
88
|
// @NOTE redirect is set to 'follow', so we shouldn't get 3xx responses here
|
|
80
89
|
if (response.status < 200 || response.status >= 300) {
|
|
81
90
|
// Always parse json for error responses
|
|
82
|
-
const payload = await readPayload(response, { parse: true })
|
|
91
|
+
const payload = await readPayload(response, { parse: true }).catch(
|
|
92
|
+
(cause) => {
|
|
93
|
+
throw new XrpcUpstreamError(
|
|
94
|
+
method,
|
|
95
|
+
response,
|
|
96
|
+
null,
|
|
97
|
+
'Unable to parse response payload',
|
|
98
|
+
{ cause },
|
|
99
|
+
)
|
|
100
|
+
},
|
|
101
|
+
)
|
|
83
102
|
|
|
103
|
+
// Properly formatted XRPC error response ?
|
|
84
104
|
if (response.status >= 400 && isXrpcErrorPayload(payload)) {
|
|
85
|
-
throw
|
|
86
|
-
method,
|
|
87
|
-
response
|
|
88
|
-
response.headers,
|
|
89
|
-
payload,
|
|
90
|
-
)
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
if (response.status >= 500) {
|
|
94
|
-
throw new XrpcUpstreamError(
|
|
95
|
-
'UpstreamFailure',
|
|
96
|
-
`Upstream server encountered an error`,
|
|
97
|
-
response,
|
|
98
|
-
payload,
|
|
99
|
-
)
|
|
105
|
+
throw response.status === 401
|
|
106
|
+
? new XrpcAuthenticationError<M>(method, response, payload)
|
|
107
|
+
: new XrpcResponseError<M>(method, response, payload)
|
|
100
108
|
}
|
|
101
109
|
|
|
110
|
+
// Invalid XRPC response (we probably did not hit an XRPC implementation)
|
|
102
111
|
throw new XrpcUpstreamError(
|
|
103
|
-
|
|
104
|
-
response.status >= 400
|
|
105
|
-
? `Upstream server returned an invalid response payload`
|
|
106
|
-
: `Upstream server returned an invalid status code`,
|
|
112
|
+
method,
|
|
107
113
|
response,
|
|
108
114
|
payload,
|
|
115
|
+
response.status >= 500
|
|
116
|
+
? 'Upstream server encountered an error'
|
|
117
|
+
: response.status >= 400
|
|
118
|
+
? 'Invalid response payload'
|
|
119
|
+
: 'Invalid response status code',
|
|
109
120
|
)
|
|
110
121
|
}
|
|
111
122
|
|
|
112
123
|
// Only parse json if the schema expects it
|
|
113
124
|
const payload = await readPayload(response, {
|
|
114
|
-
parse:
|
|
125
|
+
parse: method.output.encoding === CONTENT_TYPE_JSON,
|
|
126
|
+
}).catch((cause) => {
|
|
127
|
+
throw new XrpcUpstreamError(
|
|
128
|
+
method,
|
|
129
|
+
response,
|
|
130
|
+
null,
|
|
131
|
+
'Unable to parse response payload',
|
|
132
|
+
{ cause },
|
|
133
|
+
)
|
|
115
134
|
})
|
|
116
135
|
|
|
117
136
|
// Response is successful (2xx). Validate payload (data and encoding) against schema.
|
|
@@ -119,22 +138,22 @@ export class XrpcResponse<const M extends Procedure | Query>
|
|
|
119
138
|
// Schema expects no payload
|
|
120
139
|
if (payload) {
|
|
121
140
|
throw new XrpcUpstreamError(
|
|
122
|
-
|
|
123
|
-
`Expected response with no body, got ${payload.encoding}`,
|
|
141
|
+
method,
|
|
124
142
|
response,
|
|
125
143
|
payload,
|
|
144
|
+
`Expected response with no body, got ${payload.encoding}`,
|
|
126
145
|
)
|
|
127
146
|
}
|
|
128
147
|
} else {
|
|
129
148
|
// Schema expects a payload
|
|
130
149
|
if (!payload || !method.output.matchesEncoding(payload.encoding)) {
|
|
131
150
|
throw new XrpcUpstreamError(
|
|
132
|
-
|
|
151
|
+
method,
|
|
152
|
+
response,
|
|
153
|
+
payload,
|
|
133
154
|
payload
|
|
134
155
|
? `Expected ${method.output.encoding} response, got ${payload.encoding}`
|
|
135
156
|
: `Expected non-empty response with content-type ${method.output.encoding}`,
|
|
136
|
-
response,
|
|
137
|
-
payload,
|
|
138
157
|
)
|
|
139
158
|
}
|
|
140
159
|
|
|
@@ -144,10 +163,10 @@ export class XrpcResponse<const M extends Procedure | Query>
|
|
|
144
163
|
|
|
145
164
|
if (!result.success) {
|
|
146
165
|
throw new XrpcUpstreamError(
|
|
147
|
-
|
|
148
|
-
`Response validation failed: ${result.reason.message}`,
|
|
166
|
+
method,
|
|
149
167
|
response,
|
|
150
168
|
payload,
|
|
169
|
+
`Response validation failed: ${result.reason.message}`,
|
|
151
170
|
{ cause: result.reason },
|
|
152
171
|
)
|
|
153
172
|
}
|
|
@@ -163,17 +182,13 @@ export class XrpcResponse<const M extends Procedure | Query>
|
|
|
163
182
|
}
|
|
164
183
|
}
|
|
165
184
|
|
|
166
|
-
function shouldParse(method: Procedure | Query) {
|
|
167
|
-
return method.output.encoding === 'application/json'
|
|
168
|
-
}
|
|
169
|
-
|
|
170
185
|
/**
|
|
171
186
|
* @note this function always consumes the response body
|
|
172
187
|
*/
|
|
173
188
|
async function readPayload(
|
|
174
189
|
response: Response,
|
|
175
190
|
options?: { parse?: boolean },
|
|
176
|
-
): Promise<
|
|
191
|
+
): Promise<XrpcResponsePayload> {
|
|
177
192
|
// @TODO Should we limit the maximum response size here (this could also be
|
|
178
193
|
// done by the FetchHandler)?
|
|
179
194
|
|
|
@@ -185,18 +200,18 @@ async function readPayload(
|
|
|
185
200
|
|
|
186
201
|
// Response content-type is undefined
|
|
187
202
|
if (!encoding) {
|
|
188
|
-
// If the body is empty, return
|
|
203
|
+
// If the body is empty, return undefined (= no payload)
|
|
189
204
|
const body = await response.arrayBuffer()
|
|
190
|
-
if (body.byteLength === 0) return
|
|
205
|
+
if (body.byteLength === 0) return undefined
|
|
191
206
|
|
|
192
207
|
// If we got data despite no content-type, treat it as binary
|
|
193
208
|
return {
|
|
194
|
-
encoding:
|
|
209
|
+
encoding: CONTENT_TYPE_BINARY,
|
|
195
210
|
body: new Uint8Array(body),
|
|
196
211
|
}
|
|
197
212
|
}
|
|
198
213
|
|
|
199
|
-
if (options?.parse && encoding ===
|
|
214
|
+
if (options?.parse && encoding === CONTENT_TYPE_JSON) {
|
|
200
215
|
// @NOTE It might be worth returning the raw bytes here (Uint8Array) and
|
|
201
216
|
// perform the lex parsing using cborg/json, allowing to do
|
|
202
217
|
// bytes->LexValue in one step instead of bytes->text->JSON->LexValue.
|
|
@@ -204,22 +219,12 @@ async function readPayload(
|
|
|
204
219
|
// to @ipld/dag-json)
|
|
205
220
|
const text = await response.text()
|
|
206
221
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
// parsing to JSON then converting to Lex (?)
|
|
222
|
+
// @NOTE Using `lexParse(text)` (instead of `jsonToLex(json)`) here as
|
|
223
|
+
// using a reviver function during JSON.parse should be faster than
|
|
224
|
+
// parsing to JSON then converting to Lex (?)
|
|
211
225
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
} catch (cause) {
|
|
215
|
-
throw new XrpcUpstreamError(
|
|
216
|
-
'InvalidResponse',
|
|
217
|
-
'Invalid JSON response body',
|
|
218
|
-
response,
|
|
219
|
-
null,
|
|
220
|
-
{ cause },
|
|
221
|
-
)
|
|
222
|
-
}
|
|
226
|
+
// @TODO verify statement above
|
|
227
|
+
return { encoding, body: lexParse(text) }
|
|
223
228
|
}
|
|
224
229
|
|
|
225
230
|
return { encoding, body: new Uint8Array(await response.arrayBuffer()) }
|
package/src/types.ts
CHANGED
|
@@ -2,13 +2,40 @@ import { DidString, UnknownString } from '@atproto/lex-schema'
|
|
|
2
2
|
|
|
3
3
|
export type { DidString, UnknownString }
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Service identifier fragment for DID service endpoints.
|
|
7
|
+
*
|
|
8
|
+
* Common values include 'atproto_labeler' for labeling services,
|
|
9
|
+
* or custom service identifiers.
|
|
10
|
+
*/
|
|
5
11
|
export type DidServiceIdentifier = 'atproto_labeler' | UnknownString
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A full service proxy identifier combining a DID with a service fragment.
|
|
15
|
+
*
|
|
16
|
+
* Used to route requests through a specific service endpoint.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* ```typescript
|
|
20
|
+
* const service: Service = 'did:web:api.bsky.app#bsky_appview'
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
6
23
|
export type Service = `${DidString}#${DidServiceIdentifier}`
|
|
7
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Common options available for all XRPC calls.
|
|
27
|
+
*
|
|
28
|
+
* These options can be passed to any method that makes XRPC requests,
|
|
29
|
+
* including `xrpc()`, `call()`, and record operations.
|
|
30
|
+
*/
|
|
8
31
|
export type CallOptions = {
|
|
32
|
+
/** Labeler DIDs to request labels from for content moderation. */
|
|
9
33
|
labelers?: Iterable<DidString>
|
|
34
|
+
/** AbortSignal to cancel the request. */
|
|
10
35
|
signal?: AbortSignal
|
|
36
|
+
/** Additional HTTP headers to include in the request. */
|
|
11
37
|
headers?: HeadersInit
|
|
38
|
+
/** Service proxy identifier for routing requests through a specific service. */
|
|
12
39
|
service?: Service
|
|
13
40
|
|
|
14
41
|
/**
|
|
@@ -33,6 +60,31 @@ export type CallOptions = {
|
|
|
33
60
|
validateResponse?: boolean
|
|
34
61
|
}
|
|
35
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Valid input types for binary request bodies.
|
|
65
|
+
*
|
|
66
|
+
* These types can be used as the body for procedures that expect
|
|
67
|
+
* non-JSON content (e.g., blob uploads, binary data).
|
|
68
|
+
*
|
|
69
|
+
* @example Uploading a blob
|
|
70
|
+
* ```typescript
|
|
71
|
+
* const imageData: BinaryBodyInit = new Uint8Array(buffer)
|
|
72
|
+
* await client.uploadBlob(imageData, { encoding: 'image/png' })
|
|
73
|
+
* ```
|
|
74
|
+
*
|
|
75
|
+
* @example Streaming upload
|
|
76
|
+
* ```typescript
|
|
77
|
+
* const stream: BinaryBodyInit = someReadableStream
|
|
78
|
+
* await client.xrpc(uploadMethod, { body: stream })
|
|
79
|
+
* ```
|
|
80
|
+
*
|
|
81
|
+
* @example File upload in browser
|
|
82
|
+
* ```typescript
|
|
83
|
+
* const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement
|
|
84
|
+
* const file: BinaryBodyInit = fileInput.files[0]
|
|
85
|
+
* await client.xrpc(uploadMethod, { body: file })
|
|
86
|
+
* ```
|
|
87
|
+
*/
|
|
36
88
|
export type BinaryBodyInit =
|
|
37
89
|
| Uint8Array
|
|
38
90
|
| ArrayBuffer
|
package/src/util.ts
CHANGED
|
@@ -1,10 +1,42 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
DidString,
|
|
3
|
+
InferMethodOutput,
|
|
4
|
+
InferMethodOutputBody,
|
|
5
|
+
Procedure,
|
|
6
|
+
Query,
|
|
7
|
+
} from '@atproto/lex-schema'
|
|
2
8
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
9
|
+
/**
|
|
10
|
+
* The body type of an XRPC response, inferred from the method's output schema.
|
|
11
|
+
*
|
|
12
|
+
* For JSON responses, this is the parsed LexValue. For binary responses,
|
|
13
|
+
* this is a Uint8Array.
|
|
14
|
+
*
|
|
15
|
+
* @typeParam M - The XRPC method type (Procedure or Query)
|
|
16
|
+
*/
|
|
17
|
+
export type XrpcResponseBody<M extends Procedure | Query = Procedure | Query> =
|
|
18
|
+
InferMethodOutputBody<M, Uint8Array>
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The full payload type of an XRPC response, including body and encoding.
|
|
22
|
+
*
|
|
23
|
+
* Returns `null` for methods that have no output.
|
|
24
|
+
*
|
|
25
|
+
* @typeParam M - The XRPC method type (Procedure or Query)
|
|
26
|
+
*/
|
|
27
|
+
export type XrpcResponsePayload<
|
|
28
|
+
M extends Procedure | Query = Procedure | Query,
|
|
29
|
+
> = InferMethodOutput<M, Uint8Array>
|
|
7
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Type guard to check if a value is {@link Blob}-like.
|
|
33
|
+
*
|
|
34
|
+
* Handles both native Blobs and polyfilled Blob implementations
|
|
35
|
+
* (e.g., fetch-blob from node-fetch).
|
|
36
|
+
*
|
|
37
|
+
* @param value - The value to check
|
|
38
|
+
* @returns `true` if the value is a Blob or Blob-like object
|
|
39
|
+
*/
|
|
8
40
|
export function isBlobLike(value: unknown): value is Blob {
|
|
9
41
|
if (value == null) return false
|
|
10
42
|
if (typeof value !== 'object') return false
|
|
@@ -32,6 +64,19 @@ export function isAsyncIterable<T>(
|
|
|
32
64
|
)
|
|
33
65
|
}
|
|
34
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Builds HTTP headers for AT Protocol requests.
|
|
69
|
+
*
|
|
70
|
+
* Adds the following headers when applicable:
|
|
71
|
+
* - `atproto-proxy`: Service routing header (if service is specified)
|
|
72
|
+
* - `atproto-accept-labelers`: Comma-separated list of labeler DIDs
|
|
73
|
+
*
|
|
74
|
+
* @param options - Header building options
|
|
75
|
+
* @param options.headers - Base headers to include
|
|
76
|
+
* @param options.service - Service proxy identifier
|
|
77
|
+
* @param options.labelers - Labeler DIDs to request labels from
|
|
78
|
+
* @returns A new Headers object with AT Protocol headers added
|
|
79
|
+
*/
|
|
35
80
|
export function buildAtprotoHeaders(options: {
|
|
36
81
|
headers?: HeadersInit
|
|
37
82
|
service?: `${DidString}#${string}`
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { parseWWWAuthenticateHeader } from './www-authenticate.js'
|
|
3
|
+
|
|
4
|
+
describe(parseWWWAuthenticateHeader, () => {
|
|
5
|
+
describe('auth-params', () => {
|
|
6
|
+
it('parses single unquoted auth param', () => {
|
|
7
|
+
expect(parseWWWAuthenticateHeader('Bearer realm=example.com')).toEqual({
|
|
8
|
+
Bearer: { realm: 'example.com' },
|
|
9
|
+
})
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('parses single quoted auth param', () => {
|
|
13
|
+
expect(parseWWWAuthenticateHeader('Bearer realm="example.com"')).toEqual({
|
|
14
|
+
Bearer: { realm: 'example.com' },
|
|
15
|
+
})
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('parses quoted values with spaces', () => {
|
|
19
|
+
expect(parseWWWAuthenticateHeader('Bearer realm="my realm"')).toEqual({
|
|
20
|
+
Bearer: { realm: 'my realm' },
|
|
21
|
+
})
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('parses quoted values with escaped double quotes', () => {
|
|
25
|
+
expect(
|
|
26
|
+
parseWWWAuthenticateHeader('Bearer realm="example\\"quoted\\""'),
|
|
27
|
+
).toEqual({
|
|
28
|
+
Bearer: { realm: 'example"quoted"' },
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('parses quoted values with escaped backslash', () => {
|
|
33
|
+
expect(
|
|
34
|
+
parseWWWAuthenticateHeader('Bearer realm="path\\\\to\\\\file"'),
|
|
35
|
+
).toEqual({
|
|
36
|
+
Bearer: { realm: 'path\\to\\file' },
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('parses param names with hyphens', () => {
|
|
41
|
+
expect(
|
|
42
|
+
parseWWWAuthenticateHeader('Bearer error-uri="https://example.com"'),
|
|
43
|
+
).toEqual({
|
|
44
|
+
Bearer: { 'error-uri': 'https://example.com' },
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('parses param names with underscores', () => {
|
|
49
|
+
expect(
|
|
50
|
+
parseWWWAuthenticateHeader('Bearer error_description="test"'),
|
|
51
|
+
).toEqual({
|
|
52
|
+
Bearer: { error_description: 'test' },
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('parses param with numeric value', () => {
|
|
57
|
+
expect(parseWWWAuthenticateHeader('Bearer max-age=3600')).toEqual({
|
|
58
|
+
Bearer: { 'max-age': '3600' },
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('parses empty quoted value', () => {
|
|
63
|
+
expect(parseWWWAuthenticateHeader('Bearer realm=""')).toEqual({
|
|
64
|
+
Bearer: { realm: '' },
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('parses Basic auth challenge', () => {
|
|
69
|
+
expect(
|
|
70
|
+
parseWWWAuthenticateHeader('Basic realm="Access to staging site"'),
|
|
71
|
+
).toEqual({
|
|
72
|
+
Basic: { realm: 'Access to staging site' },
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('parses Bearer with realm', () => {
|
|
77
|
+
expect(
|
|
78
|
+
parseWWWAuthenticateHeader('Bearer realm="https://auth.example.com"'),
|
|
79
|
+
).toEqual({
|
|
80
|
+
Bearer: { realm: 'https://auth.example.com' },
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('parses DPoP with algs param', () => {
|
|
85
|
+
expect(parseWWWAuthenticateHeader('DPoP algs="ES256 RS256"')).toEqual({
|
|
86
|
+
DPoP: { algs: 'ES256 RS256' },
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('parses Digest auth challenge', () => {
|
|
91
|
+
const result = parseWWWAuthenticateHeader(
|
|
92
|
+
'Digest realm="digest-realm", nonce="abc123"',
|
|
93
|
+
)
|
|
94
|
+
expect(result).toEqual({
|
|
95
|
+
Digest: { realm: 'digest-realm', nonce: 'abc123' },
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('handle empty unquoted params', () => {
|
|
100
|
+
const result = parseWWWAuthenticateHeader('Bearer realm=')
|
|
101
|
+
expect(result).toEqual({ Bearer: { realm: '' } })
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('handle empty params', () => {
|
|
105
|
+
const result = parseWWWAuthenticateHeader('Bearer realm=""')
|
|
106
|
+
expect(result).toEqual({ Bearer: { realm: '' } })
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('treats scheme-only header as scheme with itself as token68', () => {
|
|
110
|
+
const result = parseWWWAuthenticateHeader('Basic')
|
|
111
|
+
expect(result).toEqual({ Basic: {} })
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('parses multiple challenges with commas and escaped quotes', () => {
|
|
115
|
+
const result = parseWWWAuthenticateHeader(
|
|
116
|
+
`Newauth realm="apps", type=1,\n\t title="Login to \\"apps\\"", Basic realm="simple"`,
|
|
117
|
+
)
|
|
118
|
+
expect(result).toEqual({
|
|
119
|
+
Newauth: {
|
|
120
|
+
realm: 'apps',
|
|
121
|
+
type: '1',
|
|
122
|
+
title: 'Login to "apps"',
|
|
123
|
+
},
|
|
124
|
+
Basic: { realm: 'simple' },
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('parses first challenge before comma', () => {
|
|
129
|
+
const result = parseWWWAuthenticateHeader(
|
|
130
|
+
'Basic realm="foo", Bearer realm="bar"',
|
|
131
|
+
)
|
|
132
|
+
expect(result).toEqual({
|
|
133
|
+
Basic: { realm: 'foo' },
|
|
134
|
+
Bearer: { realm: 'bar' },
|
|
135
|
+
})
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
describe('edge cases', () => {
|
|
140
|
+
it('handles empty string', () => {
|
|
141
|
+
expect(parseWWWAuthenticateHeader('')).toEqual({})
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('handles whitespace-only string', () => {
|
|
145
|
+
expect(parseWWWAuthenticateHeader(' ')).toEqual({})
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('trims whitespace from header', () => {
|
|
149
|
+
expect(parseWWWAuthenticateHeader(' Bearer realm="test" ')).toEqual({
|
|
150
|
+
Bearer: { realm: 'test' },
|
|
151
|
+
})
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('handles commas as quoted param value', () => {
|
|
155
|
+
expect(
|
|
156
|
+
parseWWWAuthenticateHeader('Bearer realm="example, with, commas"'),
|
|
157
|
+
).toEqual({ Bearer: { realm: 'example, with, commas' } })
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('handles multiple challenges with varying whitespace', () => {
|
|
161
|
+
expect(
|
|
162
|
+
parseWWWAuthenticateHeader(
|
|
163
|
+
' Bearer realm="test" , Basic rr= ',
|
|
164
|
+
),
|
|
165
|
+
).toEqual({
|
|
166
|
+
Bearer: { realm: 'test' },
|
|
167
|
+
Basic: { rr: '' },
|
|
168
|
+
})
|
|
169
|
+
})
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
describe('invalid challenges', () => {
|
|
173
|
+
it('parses single challenge with no comma correctly', () => {
|
|
174
|
+
expect(
|
|
175
|
+
parseWWWAuthenticateHeader('Bearer realm="oauth" error="invalid"'),
|
|
176
|
+
).toEqual(undefined)
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('ignores invalid challenges', () => {
|
|
180
|
+
expect(parseWWWAuthenticateHeader('Bearer realm="unclosed')).toEqual(
|
|
181
|
+
undefined,
|
|
182
|
+
)
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('handles random text without equals sign as token68', () => {
|
|
186
|
+
expect(parseWWWAuthenticateHeader('Bearer sometoken')).toEqual({
|
|
187
|
+
Bearer: 'sometoken',
|
|
188
|
+
})
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('ignores trailing whitespace after scheme', () => {
|
|
192
|
+
expect(parseWWWAuthenticateHeader('Bearer ')).toEqual({ Bearer: {} })
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('handles duplicate params (last wins)', () => {
|
|
196
|
+
expect(
|
|
197
|
+
parseWWWAuthenticateHeader('Bearer realm="first", realm="second"'),
|
|
198
|
+
).toEqual({ Bearer: { realm: 'second' } })
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('extracts valid param after invalid characters', () => {
|
|
202
|
+
expect(parseWWWAuthenticateHeader('Bearer realm@foo="bar"')).toEqual({
|
|
203
|
+
Bearer: { 'realm@foo': 'bar' },
|
|
204
|
+
})
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('ignores param with empty name', () => {
|
|
208
|
+
expect(parseWWWAuthenticateHeader('Bearer ="value"')).toEqual(undefined)
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('handles completely malformed input gracefully', () => {
|
|
212
|
+
expect(parseWWWAuthenticateHeader('!@#$%')).toEqual({ '!@#$%': {} })
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it('handles duplicate schemes as invalid', () => {
|
|
216
|
+
expect(
|
|
217
|
+
parseWWWAuthenticateHeader('Basic realm="first", Basic realm="second"'),
|
|
218
|
+
).toEqual(undefined)
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it('handles params without scheme as invalid', () => {
|
|
222
|
+
expect(parseWWWAuthenticateHeader('Bearer, realm="foo"')).toEqual(
|
|
223
|
+
undefined,
|
|
224
|
+
)
|
|
225
|
+
})
|
|
226
|
+
})
|
|
227
|
+
})
|