EcAuthDocs

@ecauth/auth-js 共有 JavaScript パッケージ 実装計画

クライアントサイド認証 JS ライブラリの実装計画。npm パッケージ構成、API、配信戦略を含む。

概要

EC-CUBE プラグイン(4系・2系)および将来のマルチプラットフォーム展開で共通利用するクライアントサイド JavaScript を npm パッケージとして管理する。

項目
パッケージ名 @ecauth/auth-js
レジストリ npmjs.com(公開パッケージ)
リポジトリ EcAuth/ecauth-auth-js
ライセンス LGPL-2.1-or-later
初回リリーススコープ WebAuthn(B2B パスキー用、既存 webauthn.js の TypeScript 化)
将来スコープ B2C パスキー、PKCE ユーティリティ、OAuth2 リダイレクトヘルパー

背景

現在 ec-cube4-ecauth/Resource/assets/js/webauthn.js に実装されている WebAuthn ヘルパーは、フレームワーク非依存の純粋な JavaScript であり、以下のすべてのユースケースで共通利用できる:

機能 Phase 必要な JS
B2B パスキー(EC-CUBE 4系) Phase 1 WebAuthn authenticate/register
B2B パスキー(EC-CUBE 2系) Phase 1 同上
B2C パスキー Phase 2 同上(パラメータが異なるのみ)
B2C OIDC フェデレーション Phase 3 PKCE code_verifier/code_challenge 生成(薄い)
B2B SSO Phase 4 リダイレクトのみ(JS 最小限)
WordPress / Magento 等 Phase 5 上記すべて

npmjs.com を選択した理由

GitHub Packages ではなく npmjs.com を採用する:

  • 認証不要: GitHub Packages は読み取りにも PAT が必要だが、npmjs.com の公開パッケージは npm install だけで取得可能
  • CI の簡素化: .npmrc の認証設定やトークン管理が不要
  • 消費者が限定的: パッケージを npm install するのは自チームのビルドプロセスのみ。EC-CUBE プラグインのエンドユーザーはプラグイン zip に同梱された UMD ファイルを利用する
  • 非公開にする意味がない: クライアントサイド JS はプラグインに同梱されて公開される

Step 1: リポジトリ作成・プロジェクト構成

1.1 GitHub リポジトリ作成

gh repo create EcAuth/ecauth-auth-js --public \
  --description "EcAuth client-side authentication library (WebAuthn, PKCE)"

1.2 ディレクトリ構成

ecauth-auth-js/
├── src/
│   ├── index.ts                   # エクスポート
│   ├── webauthn.ts                # WebAuthn authenticate/register
│   └── base64url.ts               # Base64URL encode/decode
├── tests/
│   ├── webauthn.test.ts           # WebAuthn ユニットテスト
│   └── base64url.test.ts          # Base64URL ユニットテスト
├── dist/                          # ビルド成果物(gitignore)
│   ├── ecauth-auth.esm.js         # ESM(モダンバンドラー向け)
│   ├── ecauth-auth.umd.js         # UMD(<script> タグ直接読み込み)
│   └── index.d.ts                 # 型定義
├── package.json
├── tsconfig.json
├── vite.config.ts                 # ライブラリモードビルド
├── vitest.config.ts               # テスト設定
├── .github/workflows/
│   ├── ci.yml                     # lint + テスト
│   └── publish.yml                # npm publish(Release 公開時)
├── .gitignore
├── CLAUDE.md
├── README.md
└── LICENSE

1.3 package.json

{
  "name": "@ecauth/auth-js",
  "version": "0.1.0",
  "description": "EcAuth client-side authentication library (WebAuthn, PKCE)",
  "license": "LGPL-2.1-or-later",
  "type": "module",
  "main": "dist/ecauth-auth.umd.js",
  "module": "dist/ecauth-auth.esm.js",
  "types": "dist/index.d.ts",
  "exports": {
    ".": {
      "import": "./dist/ecauth-auth.esm.js",
      "require": "./dist/ecauth-auth.umd.js",
      "types": "./dist/index.d.ts"
    }
  },
  "files": ["dist"],
  "scripts": {
    "build": "vite build",
    "test": "vitest run",
    "test:watch": "vitest",
    "lint": "tsc --noEmit"
  },
  "devDependencies": {
    "typescript": "^6.0",
    "vite": "^6.0",
    "vite-plugin-dts": "^4.0",
    "vitest": "^3.0"
  }
}

1.4 vite.config.ts

import { defineConfig } from 'vite';
import dts from 'vite-plugin-dts';

export default defineConfig({
  plugins: [dts({ rollupTypes: true })],
  build: {
    lib: {
      entry: 'src/index.ts',
      name: 'EcAuth',
      formats: ['es', 'umd'],
      fileName: (format) => `ecauth-auth.${format === 'es' ? 'esm' : 'umd'}.js`,
    },
  },
});

1.5 tsconfig.json

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "lib": ["ES2020", "DOM"],
    "strict": true,
    "declaration": true,
    "outDir": "dist",
    "rootDir": "src"
  },
  "include": ["src"]
}

Step 2: 既存 webauthn.js の TypeScript 化

2.1 src/base64url.ts

現在の webauthn.js から Base64URL 関数を分離:

/**
 * ArrayBuffer を Base64URL 文字列にエンコードする。
 */
export function base64UrlEncode(buffer: ArrayBuffer): string {
  const bytes = new Uint8Array(buffer);
  let binary = '';
  for (let i = 0; i < bytes.byteLength; i++) {
    binary += String.fromCharCode(bytes[i]);
  }
  return btoa(binary)
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/, '');
}

/**
 * Base64URL 文字列を ArrayBuffer にデコードする。
 */
export function base64UrlDecode(str: string): ArrayBuffer {
  let base64 = str.replace(/-/g, '+').replace(/_/g, '/');
  const padding = base64.length % 4;
  if (padding) {
    base64 += '===='.substring(padding);
  }
  const binary = atob(base64);
  const buffer = new ArrayBuffer(binary.length);
  const bytes = new Uint8Array(buffer);
  for (let i = 0; i < binary.length; i++) {
    bytes[i] = binary.charCodeAt(i);
  }
  return buffer;
}

2.2 src/webauthn.ts

IIFE パターンから ESM エクスポートに変換。型定義を追加:

import { base64UrlEncode, base64UrlDecode } from './base64url';

export interface AuthenticateOptions {
  optionsUrl: string;
  verifyUrl: string;
  csrfToken?: string;
  headers?: Record<string, string>;
}

export interface RegisterOptions {
  optionsUrl: string;
  verifyUrl: string;
  b2bSubject: string;
  csrfToken?: string;
  headers?: Record<string, string>;
  deviceName?: string;
}

export interface AuthenticateResult {
  redirect_url: string;
}

export interface RegisterResult {
  success: boolean;
  credential_id: string;
}

function buildHeaders(csrfToken?: string, extra?: Record<string, string>): Record<string, string> {
  const headers: Record<string, string> = { 'Content-Type': 'application/json' };
  if (csrfToken) {
    headers['X-CSRF-TOKEN'] = csrfToken;
  }
  return { ...headers, ...extra };
}

/**
 * パスキー認証を実行する。
 */
export async function authenticate(options: AuthenticateOptions): Promise<AuthenticateResult> {
  const headers = buildHeaders(options.csrfToken, options.headers);

  // 1. 認証オプション取得
  const optionsResponse = await fetch(options.optionsUrl, {
    method: 'POST',
    headers,
    body: JSON.stringify({}),
  });
  if (!optionsResponse.ok) {
    throw new Error(`Failed to get authentication options: ${optionsResponse.status}`);
  }
  const serverOptions = await optionsResponse.json();

  // 2. WebAuthn API 用にデータ変換
  const publicKeyOptions: PublicKeyCredentialRequestOptions = {
    challenge: base64UrlDecode(serverOptions.challenge),
    rpId: serverOptions.rpId,
    userVerification: serverOptions.userVerification || 'preferred',
    timeout: serverOptions.timeout || 60000,
  };

  if (serverOptions.allowCredentials?.length > 0) {
    publicKeyOptions.allowCredentials = serverOptions.allowCredentials.map(
      (cred: { id: string; type: PublicKeyCredentialType; transports?: AuthenticatorTransport[] }) => ({
        id: base64UrlDecode(cred.id),
        type: cred.type,
        transports: cred.transports || [],
      }),
    );
  }

  // 3. ブラウザ認証ダイアログ表示
  const assertion = (await navigator.credentials.get({ publicKey: publicKeyOptions })) as PublicKeyCredential;
  const assertionResponse = assertion.response as AuthenticatorAssertionResponse;

  // 4. 認証結果をサーバーに送信
  const assertionData: Record<string, unknown> = {
    response: {
      id: assertion.id,
      rawId: base64UrlEncode(assertion.rawId),
      response: {
        authenticatorData: base64UrlEncode(assertionResponse.authenticatorData),
        clientDataJSON: base64UrlEncode(assertionResponse.clientDataJSON),
        signature: base64UrlEncode(assertionResponse.signature),
        ...(assertionResponse.userHandle && {
          userHandle: base64UrlEncode(assertionResponse.userHandle),
        }),
      },
      type: assertion.type,
      clientExtensionResults: assertion.getClientExtensionResults(),
    },
  };

  const verifyResponse = await fetch(options.verifyUrl, {
    method: 'POST',
    headers,
    body: JSON.stringify(assertionData),
  });
  if (!verifyResponse.ok) {
    throw new Error(`Authentication verification failed: ${verifyResponse.status}`);
  }
  return verifyResponse.json();
}

/**
 * パスキー登録を実行する。
 */
export async function register(options: RegisterOptions): Promise<RegisterResult> {
  const headers = buildHeaders(options.csrfToken, options.headers);

  // 1. 登録オプション取得
  const requestBody: Record<string, string> = { b2b_subject: options.b2bSubject };
  if (options.deviceName) {
    requestBody.device_name = options.deviceName;
  }

  const optionsResponse = await fetch(options.optionsUrl, {
    method: 'POST',
    headers,
    body: JSON.stringify(requestBody),
  });
  if (!optionsResponse.ok) {
    throw new Error(`Failed to get registration options: ${optionsResponse.status}`);
  }
  const serverOptions = await optionsResponse.json();

  // 2. WebAuthn API 用にデータ変換
  const publicKeyOptions: PublicKeyCredentialCreationOptions = {
    challenge: base64UrlDecode(serverOptions.challenge),
    rp: { id: serverOptions.rp.id, name: serverOptions.rp.name },
    user: {
      id: base64UrlDecode(serverOptions.user.id),
      name: serverOptions.user.name,
      displayName: serverOptions.user.displayName,
    },
    pubKeyCredParams: serverOptions.pubKeyCredParams,
    authenticatorSelection: serverOptions.authenticatorSelection || {},
    timeout: serverOptions.timeout || 60000,
    attestation: serverOptions.attestation || 'none',
  };

  if (serverOptions.excludeCredentials?.length > 0) {
    publicKeyOptions.excludeCredentials = serverOptions.excludeCredentials.map(
      (cred: { id: string; type: PublicKeyCredentialType; transports?: AuthenticatorTransport[] }) => ({
        id: base64UrlDecode(cred.id),
        type: cred.type,
        transports: cred.transports || [],
      }),
    );
  }

  // 3. ブラウザ登録ダイアログ表示
  const credential = (await navigator.credentials.create({ publicKey: publicKeyOptions })) as PublicKeyCredential;
  const credentialResponse = credential.response as AuthenticatorAttestationResponse;

  // 4. 登録結果をサーバーに送信
  const transports = credentialResponse.getTransports ? credentialResponse.getTransports() : [];

  const credentialData: Record<string, unknown> = {
    response: {
      id: credential.id,
      rawId: base64UrlEncode(credential.rawId),
      response: {
        attestationObject: base64UrlEncode(credentialResponse.attestationObject),
        clientDataJSON: base64UrlEncode(credentialResponse.clientDataJSON),
        transports,
      },
      type: credential.type,
      clientExtensionResults: credential.getClientExtensionResults(),
    },
  };

  if (options.deviceName) {
    credentialData.device_name = options.deviceName;
  }

  const verifyResponse = await fetch(options.verifyUrl, {
    method: 'POST',
    headers,
    body: JSON.stringify(credentialData),
  });
  if (!verifyResponse.ok) {
    throw new Error(`Registration verification failed: ${verifyResponse.status}`);
  }
  return verifyResponse.json();
}

2.3 src/index.ts

export { base64UrlEncode, base64UrlDecode } from './base64url';
export * as webauthn from './webauthn';
export type {
  AuthenticateOptions,
  AuthenticateResult,
  RegisterOptions,
  RegisterResult,
} from './webauthn';

Step 3: テスト

3.1 vitest.config.ts

import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    environment: 'jsdom',
  },
});

3.2 tests/base64url.test.ts

Base64URL エンコード/デコードのラウンドトリップテスト。ブラウザ API 不要のためユニットテストで網羅。

3.3 tests/webauthn.test.ts

fetchnavigator.credentials をモックし、authenticate/register のリクエスト構造・エラーハンドリングを検証。


Step 4: CI/CD

4.1 .github/workflows/ci.yml

name: CI
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
      - run: npm ci
      - run: npm run lint
      - run: npm run test
      - run: npm run build

4.2 .github/workflows/publish.yml

GitHub Release 公開時に npm publish を実行:

name: Publish
on:
  release:
    types: [published]
jobs:
  publish:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          registry-url: 'https://registry.npmjs.org'
      - run: npm ci
      - run: npm run build
      - run: npm run test
      - run: npm publish --provenance --access public
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

4.3 npmjs.com の設定

  • npmjs.com で ecauth Organization を作成
  • Automation トークンを生成し、GitHub リポジトリの NPM_TOKEN シークレットに設定
  • --provenance フラグで npm provenance(GitHub Actions からのビルド証明)を付与

Step 5: 既存プラグインへの導入

5.1 ec-cube4-ecauth での利用

cd ec-cube4-ecauth
pnpm add @ecauth/auth-js

ビルドスクリプトの追加(package.json):

{
  "scripts": {
    "build:js": "cp node_modules/@ecauth/auth-js/dist/ecauth-auth.umd.js Resource/assets/js/ecauth-auth.umd.js"
  }
}

テンプレート修正(login_passkey.twig):

<!-- 修正前 -->
<script src="{{ asset('bundles/ecauthlogin43/js/webauthn.js') }}"></script>
<script>
  EcAuthWebAuthn.authenticate(optionsUrl, verifyUrl, csrfToken);
</script>

<!-- 修正後 -->
<script src="{{ asset('bundles/ecauthlogin43/js/ecauth-auth.umd.js') }}"></script>
<script>
  EcAuth.webauthn.authenticate({
    optionsUrl: optionsUrl,
    verifyUrl: verifyUrl,
    csrfToken: csrfToken
  });
</script>

リリースパッケージ(deploy.yml): UMD ファイルを Resource/assets/js/ に配置してプラグイン zip に同梱。エンドユーザーは npm を使わない。

5.2 ec-cube2-ecauth での利用(将来)

<script src="/plugin/ecauth/js/ecauth-auth.umd.js"></script>
<script>
  EcAuth.webauthn.authenticate({
    optionsUrl: '<!--{$ecauth_options_url}-->',
    verifyUrl: '<!--{$ecauth_verify_url}-->',
    csrfToken: '<!--{$transactionid}-->'
  });
</script>

5.3 WordPress / Magento 等での利用(将来)

import { webauthn } from '@ecauth/auth-js';
await webauthn.authenticate({ optionsUrl, verifyUrl });

Step 6: 将来の拡張

各 Phase で必要に応じてモジュールを追加する。

Phase 2: B2C パスキー

WebAuthn の authenticate / register はそのまま利用可能。B2C 固有のパラメータ(user_type 等)がある場合は options インターフェースを拡張。

Phase 3: PKCE ユーティリティ

// src/pkce.ts
export async function generateCodeVerifier(): Promise<string>;
export async function generateCodeChallenge(verifier: string): Promise<string>;

OIDC フェデレーション時にクライアントサイドで PKCE パラメータを生成する場合に使用。ただし EC-CUBE プラグインではサーバーサイドで PKCE を処理する設計のため、優先度は低い。

Phase 4: B2B SSO

SSO はサーバーサイドリダイレクトが主体のため、JS の追加は最小限。必要に応じてリダイレクトヘルパーを追加。


API 互換性方針

バージョン 互換性ルール
0.x Breaking changes 許容(初期開発期間)
1.0 Semantic Versioning 準拠。Breaking changes はメジャーバージョンアップ

UMD ビルドのグローバル名 EcAuth は変更しない。


依存関係図

@ecauth/auth-js (npm)
  ↑ devDependency
  ├── ec-cube4-ecauth (EC-CUBE 4系プラグイン)
  │     └── UMD を Resource/assets/js/ にコピー → プラグイン zip に同梱
  ├── ec-cube2-ecauth (EC-CUBE 2系プラグイン、将来)
  │     └── 同上
  └── wordpress-ecauth (WordPress プラグイン、将来)
        └── ESM import または UMD

実装チェックリスト

Phase 1: 初回リリース

Phase 2: B2C パスキー

Phase 3: PKCE