npm package: @helix-id/voice-auth
Description: Backend SDK for Helix Voice Authentication redirect flow
Latest version: 0.1.2

# @helix-id/voice-auth

Backend SDK for [Helix Voice Authentication](https://helix.id) — a redirect-based voice identity verification flow.

Your app redirects users to Helix, where they speak a short numeric challenge. Helix verifies their voice and redirects back with an HMAC-signed result. This package handles nonce generation, URL building, and callback verification.

## Installation

```bash
npm install @helix-id/voice-auth
```

## Quick Start

### 1. Generate the Redirect URL (backend)

```typescript
import { createAuthUrl } from "@helix-id/voice-auth";

app.get("/auth/start", (req, res) => {
  const state = Buffer.from(JSON.stringify({ action: "approve_tx", txId: "abc123", returnTo: "/dashboard" })).toString(
    "base64",
  );

  const { url, nonce } = createAuthUrl({
    spaceId: process.env.HELIX_SPACE_ID,
    state,
  });

  req.session.helixNonce = nonce;
  res.json({ url });
});
```

### 2. Redirect the User (frontend)

```typescript
const { url } = await fetch("/auth/start").then((r) => r.json());
window.location.href = url;
```

### 3. Verify the Callback (backend)

```typescript
import { verifyCallback, InvalidSignatureError, NonceMismatchError, ExpiredTimestampError } from "@helix-id/voice-auth";
import type { CallbackQuery } from "@helix-id/voice-auth";

app.get("/auth/callback", (req, res) => {
  try {
    // verifyCallback expects every field to be a plain string.
    // Frameworks like Express can parse duplicate query params as arrays
    // (e.g. ?status=verified&status=failed → { status: ["verified","failed"] }).
    // Normalize req.query before passing it in:
    const query: CallbackQuery = Object.fromEntries(
      Object.entries(req.query).map(([k, v]) => [k, Array.isArray(v) ? v[0] : String(v)]),
    ) as CallbackQuery;

    const result = verifyCallback(query, {
      nonce: req.session.helixNonce,
      secret: process.env.HELIX_SECRET,
    });

    req.session.helixNonce = null;

    const state = JSON.parse(Buffer.from(result.state, "base64").toString());

    if (result.status === "verified") {
      res.redirect(state.returnTo);
    } else {
      res.redirect(`${state.returnTo}?error=${result.statusCode}`);
    }
  } catch (error) {
    if (error instanceof InvalidSignatureError) {
      res.status(401).json({ error: "Invalid signature" });
    } else if (error instanceof NonceMismatchError) {
      res.status(401).json({ error: "Nonce mismatch" });
    } else if (error instanceof ExpiredTimestampError) {
      res.status(401).json({ error: "Callback expired" });
    } else {
      throw error;
    }
  }
});
```

## API

### `createAuthUrl(options)`

Generates a redirect URL and cryptographic nonce.

| Option    | Type     | Required | Description                                              |
| --------- | -------- | -------- | -------------------------------------------------------- |
| `spaceId` | `string` | Yes      | Your Space ID (UUID)                                     |
| `state`   | `string` | Yes      | Opaque state echoed back in callback                     |
| `baseUrl` | `string` | No       | Override Helix URL (default: `https://capsule.helix.id`) |

Returns `{ url: string, nonce: string }`. Store `nonce` in the user's session.

### `verifyCallback(query, options)`

Verifies the HMAC-signed callback and parses the result.

| Option   | Type     | Required | Description                                   |
| -------- | -------- | -------- | --------------------------------------------- |
| `nonce`  | `string` | Yes      | Nonce from `createAuthUrl`, stored in session |
| `secret` | `string` | Yes      | Shared secret from Helix admin panel          |

Returns `{ status, statusCode, logId, state }`.

Throws: `InvalidSignatureError`, `NonceMismatchError`, `ExpiredTimestampError`, `MalformedCallbackError`.

> **Important:** `query` must be a flat `Record<string, string>`. Some frameworks (Express, Koa) can parse duplicate query parameters as arrays. Normalize the query object before passing it to `verifyCallback` — see the callback example above.

## Status Codes

| status_code                | status     | Description                                |
| -------------------------- | ---------- | ------------------------------------------ |
| `voice_verified`           | `verified` | Voice matched successfully                 |
| `voice_enrolled`           | `verified` | First-time user — voice profile created    |
| `challenge_mismatch`       | `failed`   | Spoken words didn't match displayed digits |
| `synthetic_voice_detected` | `failed`   | Audio flagged as AI-generated or spoofed   |
| `voice_mismatch`           | `failed`   | Voice didn't match enrolled profile        |

## Testing

Use the test credentials for local development:

```typescript
import { createAuthUrl } from "@helix-id/voice-auth";
import { HELIX_TEST_SPACE_ID, HELIX_TEST_SECRET } from "@helix-id/voice-auth/testing";

const { url, nonce } = createAuthUrl({
  spaceId: HELIX_TEST_SPACE_ID,
  state: "test",
});
```

| Credential   | Value                                         |
| ------------ | --------------------------------------------- |
| Space ID     | `a1b2c3d4-e5f6-7890-abcd-ef1234567890`        |
| Secret       | `tk_test_hx_4a7f2c9d8e1b3f6a5c2d9e8f7b4a1c3d` |
| Callback URL | `http://localhost:8080/helix/callback`        |

## License

MIT