📦

pauth-js で5分で実装する着信認証 — npm install 1コマンドでSMS認証を代替する

に公開

1. はじめに

着信認証API pauth.me を素のcurlで呼ぶとこうなります。

Before(SDKなし / curl):

# 1. トークン取得
TOKEN=$(curl -s -H 'api-key: test_xxx' https://pauth.me/api/v1/auth | jq -r .token)

# 2. SSEストリームで PIN を待つ(手動パース)
curl -s -N -H "Authorization: Bearer $TOKEN" \
  https://pauth.me/api/v1/entry/+819012345678 | \
  grep -o '"pin":"[^"]*"' | head -1

# 3. PIN照合
curl -s -H "Authorization: Bearer $TOKEN" \
  "https://pauth.me/api/v1/apply?callerd_number=+819012345678&pin=1234"

After(pauth-js SDK):

import { PauthClient } from 'pauth-js';
const client = new PauthClient({ apiKey: 'test_xxx' });
const result = await client.verify('+819012345678');
console.log(result.pin); // "1234"

3エンドポイントの呼び出し、SSEストリームのバッファリング、トークン管理、エラーハンドリング——これらをSDKが全部吸収します。この記事は「pauth-jsをプロジェクトに組み込む側」の実装チュートリアルです。APIの内部設計(Asterisk/AGI/SSE)は第1弾で、予約システムへの応用は第2弾で解説しています。


2. インストール & セットアップ

# GitHubから直接インストール(npm publish準備中)
npm install github:mskz-ptplus-jp/pauth-js

APIキーの取得:

  1. pauth.me でサインアップ
  2. ダッシュボードから APIキーを発行(無料枠: 100回/月)

2種類のキー:

キー 用途 実電話発信
test_xxx 開発・テスト(サンドボックス) なし(約2秒でPIN=1234が自動返却)
live_xxx 本番 あり(実際に着信が発生)

TypeScriptプロジェクトの場合、型定義は同梱されているので @types/pauth-js は不要です。


3. 30秒クイックスタート

import { PauthClient } from 'pauth-js';

const client = new PauthClient({
  apiKey: 'test_your-api-key'  // サンドボックスモード(無料100回/月)
});

async function main() {
  // verify() = auth() + entry() + apply() を一括実行
  const result = await client.verify('+819012345678');
  console.log('認証成功!', result.pin); // "1234"(サンドボックス)
}

main().catch(console.error);

実行すると約2秒後に 認証成功! 1234 と出力されます。サンドボックスモードでは実際の電話発信は不要——開発マシンから即座に動作確認できます。

verify() が内部でやっていること:

  1. /api/v1/auth を呼んでBearer tokenを取得
  2. /api/v1/entry/{phone} にSSE接続してPIN待機
  3. PINが届いたら /api/v1/apply でPIN照合
  4. 認証成功の結果オブジェクトを返却

4a. 実践: Node.js バックエンドでの認証フロー

Expressで電話番号認証エンドポイントを実装する例です。

// server.ts
import express from 'express';
import { PauthClient, RateLimitError, ConflictError } from 'pauth-js';

const app = express();
app.use(express.json());

const pauth = new PauthClient({
  apiKey: process.env.PAUTH_API_KEY || 'test_your-api-key'
});

// POST /api/verify-phone
app.post('/api/verify-phone', async (req, res) => {
  const { phoneNumber } = req.body as { phoneNumber: string };

  try {
    const result = await pauth.verify(phoneNumber, {
      onStep: (step) => console.log(`Auth step: ${step}`)
      // step: 'authenticating' → 'waiting_for_call' → 'verifying_pin'
    });
    res.json({ success: true, pin: result.pin });
  } catch (err) {
    if (err instanceof RateLimitError) {
      // 同一番号への連続リクエスト制限(デフォルト: 10分に1回)
      res.status(429).json({
        error: 'レート制限。しばらくお待ちください',
        retryAfter: err.retryAfter
      });
    } else if (err instanceof ConflictError) {
      // 同じ番号が既に認証待ち中
      res.status(409).json({ error: '認証が既に進行中です' });
    } else {
      res.status(500).json({ error: '認証に失敗しました' });
    }
  }
});

app.listen(3000, () => console.log('Server running on port 3000'));

注意: pauth-js はサーバーサイド専用パッケージです。APIキーをクライアントに露出させないため、フロントエンドから直接呼び出してはいけません。バックエンドのAPIルートやサーバーアクションから呼ぶ構成にしてください。

エラーハンドリングの整理:

エラークラス HTTPステータス 意味 対処
RateLimitError 429 レート制限超過 err.retryAfter 秒後に再試行
ConflictError 409 認証進行中 ユーザーに案内して待機
AuthenticationError 401 APIキー不正 キーを再確認
PauthError(基底) 500 その他 ログ記録してフォールバック

4b. 実践: Next.js/React での認証フォーム

設計の原則: APIキーをフロントに露出させない。

Next.js App Router を使い、API Route でバックエンドを経由する構成です。

// app/api/verify/route.ts(サーバーサイド)
import { NextRequest, NextResponse } from 'next/server';
import { PauthClient } from 'pauth-js';

// PauthClient はモジュールスコープで初期化してトークンを使い回す
const client = new PauthClient({ apiKey: process.env.PAUTH_API_KEY! });

export async function POST(req: NextRequest) {
  const { phoneNumber } = await req.json() as { phoneNumber: string };
  try {
    const result = await client.verify(phoneNumber);
    return NextResponse.json({ success: true, pin: result.pin });
  } catch (err) {
    const status = err instanceof Error && 'statusCode' in err
      ? (err as { statusCode: number }).statusCode
      : 500;
    return NextResponse.json({ error: '認証失敗' }, { status });
  }
}
// app/verify/page.tsx(クライアントサイド)
'use client';
import { useState } from 'react';

export default function VerifyPage() {
  const [phone, setPhone] = useState('');
  const [result, setResult] = useState<string | null>(null);
  const [error, setError] = useState<string | null>(null);
  const [loading, setLoading] = useState(false);

  async function handleVerify() {
    setLoading(true);
    setError(null);
    try {
      const res = await fetch('/api/verify', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ phoneNumber: phone }),
      });
      const data = await res.json() as { pin?: string; error?: string };
      if (res.ok && data.pin) {
        setResult(data.pin);
      } else {
        setError(data.error ?? '認証失敗');
      }
    } catch {
      setError('ネットワークエラー');
    } finally {
      setLoading(false);
    }
  }

  return (
    <div>
      <input
        value={phone}
        onChange={e => setPhone(e.target.value)}
        placeholder="+819012345678"
        disabled={loading}
      />
      <button onClick={handleVerify} disabled={loading || !phone}>
        {loading ? '認証中...' : '認証する'}
      </button>
      {result && <p>認証PIN: {result}</p>}
      {error && <p style={{ color: 'red' }}>{error}</p>}
    </div>
  );
}

フロントエンドは /api/verify を叩くだけ。PAUTH_API_KEY.env.local に保管し、process.env 経由でサーバーサイドのみアクセスします。


5. SDK内部の工夫

pauth-js が内部で解決している技術課題を紹介します(アーキテクチャの深掘りは第1弾参照)。

SSEストリームの抽象化

ブラウザの EventSource API はカスタムヘッダーが設定できないため、Authorization: Bearer xxx を渡せません。pauth-js では fetch() + ReadableStream でSSEを実装しています。

// 概念コード(実装の要点)
const response = await fetch(entryUrl, {
  headers: { Authorization: `Bearer ${token}` }
});
const reader = response.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';

while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  buffer += decoder.decode(value, { stream: true });

  // SSEはチャンク分割されることがある → バッファで "\n\n" 区切りまで溜める
  const lines = buffer.split('\n\n');
  buffer = lines.pop() ?? '';

  for (const line of lines) {
    if (line.startsWith('data: ')) {
      const data = JSON.parse(line.slice(6));
      if (data.pin) return data;  // PIN到着 → 終了
    }
  }
}

チャンクが途中で分割されてくることへの対策として、\n\n(SSEイベント区切り)まで溜めてからJSONパースしています。

トークン自動管理

Sanctumトークンの有効期限は600秒。SDKはトークンを内部キャッシュし、残り30秒を切ったら次の呼び出し前に自動リフレッシュします。複数リクエストが並行しても、リフレッシュは1回だけ走る排他制御付きです。

エラー階層

PauthError(基底クラス)
├── AuthenticationError  // 401: APIキー不正
├── ConflictError        // 409: 認証進行中
└── RateLimitError       // 429: レート制限(retryAfter フィールドあり)

すべてのエラーが PauthError を継承しているため、catch (err)err instanceof PauthError という分岐だけでSDKエラーを一括捕捉できます。


6. Twilio SMS SDK との比較

同じ「電話番号で本人確認」の文脈で、Twilio Verify SDK と比べます。まず Twilio での実装コードを示します(公式ドキュメントベース)。

// twilio の例(Verify V2 API)
const twilio = require('twilio');
const client = new twilio(accountSid, authToken);

// 認証コード送信
await client.verify.v2
  .services(serviceId)
  .verifications.create({
    to: '+819012345678',
    channel: 'sms'
  });
// → SMSで「Your verification code is: 123456」が届く

// コード検証
const check = await client.verify.v2
  .services(serviceId)
  .verificationChecks.create({
    to: '+819012345678',
    code: '123456'
  });
console.log(check.status); // "approved" or "pending"
// pauth-js の例(同等の処理)
import { PauthClient } from 'pauth-js';
const client = new PauthClient({ apiKey: process.env.PAUTH_API_KEY! });
const result = await client.verify('+819012345678');
console.log(result.pin); // "1234"

比較表:

項目 Twilio Verify (SMS) pauth-js (着信認証)
インストール npm install twilio npm install github:mskz-ptplus-jp/pauth-js
認証実装行数 ~20行(送信+照合+エラー) ~5行(verify 1メソッド)
コスト/件 $0.05〜0.10 USD(約¥8〜15) $0.05 USD(約¥7)
固定電話対応 ×
サンドボックス 試用クレジット要 無料100回/月
TypeScript型定義 @types/twilio(別途) 同梱
外部依存(node_modules) 多数 0

※Twilio料金は2026年2月時点の公式料金ページに基づく概算。

pauth-jsの強み:

  • 固定電話対応: 高齢者・法人ユーザーが多い業態(医療・旅館など)ではカバレッジが変わる
  • 依存ゼロ: node_modules が肥大化しない、セキュリティ監査が容易
  • コスト同等以下: SMS認証より若干安く、サンドボックスが完全無料

7. まとめ

npm install github:mskz-ptplus-jp/pauth-js の1コマンドで、着信認証を verify() の1メソッドに集約できます。

  • 5分で動作確認: サンドボックスモード(test_ APIキー)で実電話不要
  • TypeScript対応: 型定義同梱、外部依存ゼロ
  • 固定電話・携帯・IP電話すべてに対応: SMS認証では届かないユーザーをカバー
  • 無料枠: 月100回まで無料(小規模サービスなら本番でも無料運用可)

関連リンク:

Discussion