Skip to content

login() swallows text/plain auth error messages (e.g. "Invalid Email.") as "Failed to parse JSON" #305

@c-reichert

Description

@c-reichert

Summary

When Bring.login() is called with credentials the Bring API rejects, the package surfaces a generic Cannot Login: Failed to parse JSON (Node 20+) / Cannot Login: Unexpected token I in JSON at position 0 (Node 18) instead of the actual auth error returned by Bring. The real message — e.g. Invalid Email. — is silently discarded.

Root cause

In src/bring.ts, the login() method calls resp.json() unconditionally:

async login(): Promise<void> {
    let data: AuthResponse;

    try {
        const resp = await fetch(`${this.url}bringauth`, {
            method: 'POST',
            body: new URLSearchParams({ email: this.mail, password: this.password })
        });

        data = await resp.json();          // <-- throws SyntaxError on text/plain bodies
    } catch (e: any) {
        throw new Error(`Cannot Login: ${e.message}`);  // <-- now contains \"Failed to parse JSON\", not the real error
    }

    if ('error' in data) {
        throw new Error(`Cannot Login: ${data.message}`);  // <-- never reached for text/plain errors
    }
    
}

For some failure modes the Bring backend responds with Content-Type: text/plain instead of JSON — at minimum I've observed it for bad credentials (Invalid Email.), and the same pattern can also surface for rate-limit notices, maintenance pages, and Cloudflare challenges. resp.json() throws, the catch block swallows the original body, and the caller has no way to tell wrong-email from wrong-password from rate-limited.

Reproduction

import Bring from 'bring-shopping';

const c = new Bring({ mail: 'definitely-not-a-real-bring-user@example.com', password: 'whatever' });
try {
  await c.login();
} catch (e) {
  console.error(e.message);
}

Actual output (Node 20+):

Cannot Login: Failed to parse JSON

Expected output:

Cannot Login: Invalid Email.

(Or some structured equivalent that preserves the server's actual message.)

Raw response from the API in this case:

POST https://api.getbring.com/rest/v2/bringauth
→ 401
Content-Type: text/plain
Body: Invalid Email.

Suggested fix

Read the body as text first, then conditionally parse based on Content-Type and surface non-JSON bodies verbatim:

async login(): Promise<void> {
    let resp: Response;
    let bodyText: string;
    try {
        resp = await fetch(`${this.url}bringauth`, {
            method: 'POST',
            body: new URLSearchParams({ email: this.mail, password: this.password })
        });
        bodyText = await resp.text();
    } catch (e: any) {
        throw new Error(`Cannot Login: ${e.message}`);
    }

    const ctype = resp.headers.get('content-type') || '';

    if (!resp.ok || !ctype.includes('application/json')) {
        // text/plain, HTML, empty body, etc. — surface the server's actual message
        const snippet = bodyText.trim().slice(0, 500) || '(empty body)';
        throw new Error(`Cannot Login: ${resp.status} ${snippet}`);
    }

    let data: AuthResponse;
    try {
        data = JSON.parse(bodyText);
    } catch (e: any) {
        throw new Error(`Cannot Login: malformed JSON (status ${resp.status}): ${bodyText.slice(0, 200)}`);
    }

    if ('error' in data) {
        throw new Error(`Cannot Login: ${data.message}`);
    }
    
}

This keeps the existing error shape (a thrown Error whose message starts with Cannot Login:) while preserving the diagnostic the API actually returned. The same pattern is worth considering for the other endpoints (loadLists, getItems, …) which have the same resp.json()'error' in data structure and the same failure mode under e.g. rate-limit responses.

Workaround (for users hitting this today)

We currently do a manual pre-flight POST /bringauth ourselves, inspect Content-Type + body, and surface the real Bring message before calling c.login(). Happy to open a PR with the change above if it would help.

Versions

  • bring-shopping@2.0.1
  • Confirmed against current master (login() unchanged from the published build)
  • Node.js 20.x / Bun 1.x (both undici and Bun's native fetch behave the same way — resp.json() throws SyntaxError on non-JSON bodies)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions