Skip to content
typesafe-docsPublic

About

Parser and serializer for HTTP WWW-Authenticate, Proxy-Authenticate and Authorization headers (RFC 9110 section 11), with correct multi-challenge handling.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

wwwauth

A parser and serializer for HTTP WWW-Authenticate, Proxy-Authenticate, and Authorization header values, per RFC 9110 section 11.

import { parseChallenges } from 'wwwauth';

const header = 'Newauth realm="apps", type=1, title="Login to \\"apps\\"", Basic realm="simple"';
const challenges = parseChallenges(header);

challenges.length;                  // 2
challenges[0].scheme;                // "Newauth"
challenges[0].params.get('title');   // 'Login to "apps"'
challenges[1].scheme;                // "Basic"
challenges[1].params.get('realm');   // "simple"

Why

RFC 9110 section 11.6.1 gives this exact header as its worked example of WWW-Authenticate, and says it is two challenges: Newauth (with realm, type, and title) and Basic (with realm). The reason it's tricky: a challenge's own auth-params are comma-separated, at the same syntactic level as the commas that separate one challenge from the next -- you cannot split on top-level commas alone; you have to notice that Basic realm="simple" does not fit the shape of a continuing name=value pair.

Every parser checked gets this wrong, in three different ways:

  • auth-header@1.0.0 merges both challenges into one, filing Basic as a second value of the realm parameter and losing the Basic scheme entirely as a token:
    { scheme: 'Newauth', params: { realm: ['apps', 'simple'], type: '1', title: 'Login to "apps"' }, token: 'Basic' }
    
  • www-authenticate@0.6.3 throws outright on this exact header (its tokenizer chokes on the escaped quote once the parameter list runs past the first challenge): {"scheme":"","parms":{},"err":"Unexpected token (apps\\) after Login to \\\""}. On a header without that escape it doesn't throw, but still folds the second challenge in under a garbage key: { scheme: 'Digest', parms: { realm: '...', qop: '...', nonce: '...', 'Basic realm': 'simple' } }.
  • bearerparse (github.com/prasad-a-abhishek/bearerparse, no npm package) silently drops the second challenge: { scheme: 'newauth', params: { realm: 'apps', type: '1', title: 'Login to "apps"' } } -- no trace of Basic anywhere, and no error.

Also checked and not covering: header-parsers@0.6.0 (labeled RFC 9110, but only parses Authorization, no WWW-Authenticate/challenge parsing at all); auth-param-parser@1.0.1 (parses a bare auth-param list only -- throws given a scheme-prefixed header, so it can't parse a challenge at all, only an already-isolated param list); express-authentication-header (vendors the same auth-header package and inherits its bug). Node core and undici@8.11.2 have no WWW-Authenticate parser to check. Chromium's network stack (net/http/http_auth_challenge_tokenizer.{h,cc}, read via gh api against the klzgrad/naiveproxy mirror) sidesteps the problem rather than solving it: it marks WWW-Authenticate/Proxy-Authenticate as non-coalescing headers (http_response_headers.cc's kNonCoalescingHeaders), so repeated header lines are kept separate, but within one header line its tokenizer takes only the first space-separated token as the scheme and treats the rest of that one line as an opaque blob -- it does not attempt to split multiple challenges appearing on a single line at all. In practice this works because servers overwhelmingly send one challenge per header line; it would mishandle the RFC's own Newauth/Basic example if a server sent it as written, on one line.

Measured: node bench/compare-incumbents.mjs (1000 generated cases from the same generator as the round-trip test, each server-reachable package installed with npm install --no-save auth-header@1.0.0 www-authenticate@0.6.3, then npm run build):

Corpus: 1000 generated cases (same generator as the round-trip test).

wwwauth               1000/1000 exact-shape matches
auth-header@1.0.0       81/1000 exact-shape matches  (335 threw)
www-authenticate@0.6.3    25/1000 exact-shape matches  (57 threw)
bearerparse (github)    83/1000 exact-shape matches

RFC 9110 11.6.1 flagship example:
  wwwauth:           ["Newauth","Basic"]
  auth-header:       {"scheme":"Newauth","token":"Basic"}
  www-authenticate:  {"scheme":"","parmKeys":[]}
  bearerparse:       {"scheme":"newauth","params":{"realm":"apps","type":"1","title":"Login to \"apps\""}}

The "exact-shape match" metric only requires the right number of challenges plus the first challenge's scheme and param count -- a much lower bar than wwwauth's own full-structure round-trip (also 1000/1000, and 5000/5000 in the committed test), so these numbers are generous to the incumbents, not harsh.

Differential against a second implementation

Go's net/http and golang.org/x/net have no WWW-Authenticate/challenge parser to differential-test against (checked gh search code and the package docs). Python's stdlib does: urllib.request.AbstractBasicAuthHandler parses Basic realms out of WWW-Authenticate with a hand-rolled regex (re.compile('(?:^|,)\s*([^ \t]+)\s+realm=(["\']?)([^"\']*)\2', re.I)). Measured directly (Python 3, stdlib only):

>>> h.rx.findall('Basic charset="UTF-8", realm="simple"')
[]   # fails when realm isn't the parameter immediately after the scheme
>>> h.rx.findall(r'Basic realm="a \"b\" c"')
[('Basic', '"', 'a \\')]   # truncates at the first embedded quote; no quoted-pair handling

wwwauth handles both correctly (auth-param order is never assumed, and \" inside a quoted-string decodes to "), which is unsurprising given Python's version is a one-line regex built for a narrower job (finding a Basic realm for its own auth handler), not a general challenge parser.

Installation

npm install wwwauth

Node >= 20.6. No runtime dependencies.

Usage

Reading a challenge from a response

import { parseChallenges } from 'wwwauth';

const challenges = parseChallenges(response.headers.get('www-authenticate'));
for (const c of challenges) {
  if (c.scheme.toLowerCase() === 'bearer') {
    console.log(c.params.get('error'), c.params.get('scope'));
  }
}

Pass an array to handle a header sent as several repeated field lines (e.g. IncomingMessage.headers['www-authenticate'] when a Node server process combined them, or your own collection of raw header lines). Each element is parsed independently and the results are concatenated -- deliberately not joined with a comma first (see "How it works").

Building an Authorization header

import { basicCredentials } from 'wwwauth';

request.headers.set('authorization', basicCredentials('alice', 'hunter2'));

Bearer, Basic, and Digest helpers

import { parseBearerChallenge, parseBasicChallenge, parseDigestChallenge } from 'wwwauth';

parseBearerChallenge(challenge);  // { realm?, scope?, error?, error_description?, error_uri? }
parseBasicChallenge(challenge);   // { realm?, charset? } -- throws if charset isn't "UTF-8"
parseDigestChallenge(challenge);  // { realm?, domain?, nonce?, opaque?, stale?, algorithm?, qop?, charset?, userhash? }

Strict vs. lenient parsing

parseChallenges(header, { mode: 'strict' });   // throws AuthHeaderParseError on any deviation from the grammar
parseChallenges(header, { mode: 'lenient' });  // the default -- recovers, see "Lenient mode" below

Reference

parseChallenges(input, opts?) => Challenge[]

Parses a WWW-Authenticate or Proxy-Authenticate header value. input is a string or string[] (repeated header instances).

serializeChallenge(challenge) => string / serializeChallenges(challenges) => string

Serialize one challenge, or a list back to one header value (joined with ", "). Quotes a param value only when it is not already a valid bare token. Throws RangeError if a challenge carries both token68 and params (the grammar allows only one), or if a value contains an octet that cannot be represented in a quoted-string at all (a raw control character other than HTAB).

parseAuthorization(input, opts?) => Credentials / serializeAuthorization(credentials) => string

Same grammar engine, for the single credentials value in an Authorization/Proxy-Authorization header rather than a list of challenges.

Challenge / Credentials

Field Type Description
scheme string As written on the wire. Compare with .toLowerCase() -- RFC 9110 11.4 says auth-scheme is case-insensitive.
token68 string | undefined Present for schemes using the opaque-blob form (legacy Basic, Bearer, Negotiate/NTLM). Mutually exclusive with params.
params ParamList Ordered [name, value][], plus .get(name), .getAll(name), and .duplicates.

ParamList is a real Array, not a subclass of one (see "How it works"). .get/.getAll compare name case-insensitively; .get returns the first match. .duplicates lists (lowercased) names that occurred more than once -- RFC 9110 11.4 says each parameter name MUST occur only once per challenge, but this library keeps every occurrence rather than silently dropping one, and surfaces the repeats here so a caller can decide how to react.

ParseOptions

Name Type Default Description
mode 'strict' | 'lenient' 'lenient' 'strict' throws AuthHeaderParseError on any grammar deviation or duplicate param name. 'lenient' recovers -- see below.

Bearer (RFC 6750)

  • parseBearerChallenge(challenge) => { realm?, scope?, error?, error_description?, error_uri? }
  • bearerCredentials(token) => string -- builds Bearer <token>, validating the b64token grammar.
  • parseBearerCredentials(credentials) => string -- extracts the token from parsed Authorization credentials.

Basic (RFC 7617)

  • parseBasicChallenge(challenge) => { realm?, charset? } -- throws if charset is present and isn't "UTF-8" (case-insensitively), the only value RFC 7617 section 2.1 permits.
  • basicCredentials(userid, password) => string -- throws if userid contains a colon (forbidden by RFC 7617 section 2).
  • parseBasicCredentials(credentials) => { userid, password }

Digest (RFC 7616), parse only

  • parseDigestChallenge(challenge) => { realm?, domain?, nonce?, opaque?, stale?, algorithm?, qop?, charset?, userhash? } domain is split on whitespace; qop is split out of its quoted comma-separated form (qop="auth,auth-int" -> ['auth', 'auth-int']); stale/userhash are booleans. This library does not compute a Digest response.

Errors

  • AuthHeaderError -- base class.
  • AuthHeaderParseError -- thrown in strict mode; carries .item, the raw list element that failed, when known.

How it works

The header value is first split into its top-level comma-separated list items, ignoring commas inside a quoted-string (RFC 9110 5.6.1's list syntax; empty items from leading/trailing/doubled commas are dropped, as the RFC requires tolerating). Each item is then classified independently:

  1. If a challenge is already open (and isn't a token68 challenge) and the item matches auth-param (token BWS "=" BWS (token / quoted-string)) in full, it's a continuation parameter of that challenge.
  2. Otherwise the item must match auth-scheme [1*SP (token68 / auth-param)] in full: it starts a new challenge. A token immediately followed by = (no space) can never reach this branch, so type=1 is never mistaken for a scheme named type; conversely Basic realm="simple" fails step 1 (there's a bare Basic token before any =) and is correctly read as a new scheme with one param.

ParamList is a plain Array with get/getAll/duplicates attached via Object.defineProperty (non-enumerable, so JSON.stringify stays a clean array of pairs) rather than a subclass of Array. Subclassing looks more natural but breaks the first time a caller calls .filter()/.map()/ .slice() on it: those methods construct a new instance of the subclass through Symbol.species, calling new ParamList(someLength) with a bare number, which does not match a constructor that expects a list of entries.

Repeated header instances (parseChallenges(string[])) are parsed one value at a time and concatenated, never joined into one string with a comma first -- matching Chromium's choice to treat this header as non-coalescing (see "Why"). Joining first risks one line's trailing bare scheme or token68 bleeding into the next line's first item.

Lenient mode

Lenient mode (the default) recovers from two specific defects rather than throwing:

  • A list item that is neither a valid continuation param nor a valid new challenge start is dropped, and parsing continues with the rest of the header. No incumbent or reference implementation checked models this exact recovery rule (Chromium's tokenizer, read for this project, simply doesn't attempt multi-challenge splitting within one header line at all, so there's nothing there to diverge from on this point) -- it follows directly from RFC 9110's own guidance that recipients should tolerate unknown or malformed extension content rather than fail the whole header.
  • An unterminated quoted-string (a " with no matching close before the item ends) is recovered by treating the remainder of the item as the value, matching the general "accept what you can, don't reject the whole field" posture user agents take toward this header rather than any one library's specific behavior.

Strict mode disables both recoveries and additionally rejects a duplicate parameter name within one challenge (RFC 9110 11.4's MUST).

Limitations

  • quoted-string values are handled as JavaScript strings (UTF-16), not raw octets. A byte in the obs-text range (0x80-0xFF) that isn't valid UTF-8 on its own will round-trip incorrectly; RFC 9110 itself only defines obs-text for historical compatibility and recommends against producing it.
  • serializeChallenge cannot represent a value containing a raw control character (other than HTAB) -- there is no legal quoted-pair for it; it throws RangeError rather than emit invalid output.
  • Digest support is parse-only: no response computation, no nc/cnonce generation. Building Digest credentials is out of scope for this package.
  • No built-in credential storage, retry loop, or fetch/http.request integration -- this library only parses and serializes header values.
  • The lenient-mode "drop the bad item and continue" rule was designed against the RFC's own examples and the generated corpus below, not against a specific browser's exact behavior for every malformed input; see "Lenient mode" above for what was and wasn't checked against Chromium's source.

Compatibility

Tested on Node 24.20.0 (macOS, arm64) via node --test, and the built dist/ smoke suite on the same Node version. test.yml also runs the dist/ smoke suite on Node 20.6 and 22 in CI; those runtimes were not exercised locally for this package.

Development

npm install
npx tsc --noEmit
npm test                              # TypeScript suite, node --test
npm run build && npm run test:dist    # what the oldest supported runtimes get

npm install --no-save auth-header@1.0.0 www-authenticate@0.6.3
npm run bench                         # incumbent comparison (bearerparse fetched automatically, pinned+sha256'd)

License

MIT. See LICENSE.

About

Parser and serializer for HTTP WWW-Authenticate, Proxy-Authenticate and Authorization headers (RFC 9110 section 11), with correct multi-challenge handling.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages