liff-param-persist
v1.0.0
Published
LINE LIFF authentication with secure parameter persistence across redirects
Downloads
7
Maintainers
Readme
🚀 LIFF Param Persist
LINE LIFF認証でのパラメータ永続化ユーティリティ
データベース、キーバリューストア、localStorage、Cookieを使用せずに、LINE認証のリダイレクト間でパラメータを安全に永続化するTypeScriptライブラリです。AES-GCM-256暗号化によるエンタープライズグレードのセキュリティを提供しながら、シンプルな3行統合パターンを維持します。
✨ 特徴
- 🔐 エンタープライズセキュリティ: HKDFキー導出によるAES-GCM-256暗号化
- ⚡ 3行統合: シンプルな
import、init、runパターン - 🚫 外部ストレージ不要: データベース、KVストア、localStorage、Cookie不要
- 🛡️ リプレイ攻撃対策: 180秒のトークン有効期限とオリジンバインディング
- 🔄 信頼性の高いフォールバック: 最大限の信頼性を実現するwindow.nameバックアップ機能
- 📱 TypeScript ファースト: 包括的なJSDocドキュメントによる完全な型安全性
- 🌐 クロスオリジン安全: 異なるドメイン/プロトコル間でのトークン使用を防止
🚀 クイックスタート
インストール
npm install liff-param-persist基本的な使用方法(3行)
import { createLiffAuth } from 'liff-param-persist';
const auth = createLiffAuth(); // 1行目
await auth.init({ liffId: 'your-liff-id', cipherPassphrase: 'your-secret' }); // 2行目
const result = await auth.run({ userId: 123, returnUrl: '/dashboard' }); // 3行目
console.log('LINE ユーザーID:', result.lineUserId);
console.log('復元されたパラメータ:', result.params);📖 目次
🤔 なぜ外部ストレージが不要なのか?
従来のアプローチの問題点
ほとんどの認証ライブラリは外部ストレージソリューションを必要とします:
// ❌ 従来のアプローチ - インフラが必要
localStorage.setItem('auth-params', JSON.stringify(params)); // プライバシーの懸念
await redis.set(`session:${sessionId}`, params); // インフラの複雑さ
await db.sessions.create({ sessionId, params }); // データベース依存革新的なソリューション
Kiro LIFF Authは暗号化ステート封緘により外部ストレージを不要にします:
// ✅ LIFF Param Persist - 純粋なクライアントサイドソリューション
const auth = createLiffAuth();
await auth.init({ liffId: 'your-liff-id', cipherPassphrase: 'secret' });
const result = await auth.run(params); // パラメータはLINE認証を通じて安全に送信される技術的な仕組み
- ステート封緘: パラメータはAES-GCM-256で暗号化され、LINE認証URLに埋め込まれます
- 暗号化バインディング: 暗号化キーはHKDFを使用して
origin + liffId + passphraseから導出されます - 改ざん検出: AES-GCM認証タグがパラメータの変更を防止します
- フォールバック機能: ステートトークンが失敗した場合、window.nameが信頼性の高いバックアップを提供します
メリット
- 🏗️ ゼロインフラ: データベース、Redis、外部サービス不要
- 🔒 プライバシー強化: パラメータはユーザーのブラウザ環境から出ることがありません
- ⚡ 即座のデプロイ: バックエンドセットアップなしで即座に動作
- 💰 コスト効率: ストレージコストやインフラメンテナンス不要
- 🌍 グローバルスケール: 地域的なストレージ考慮なしでどこでも動作
🔐 セキュリティアーキテクチャ
暗号化仕様
- アルゴリズム: AES-GCM-256 (AEAD - 関連データ付き認証暗号)
- キー導出: PBKDF2付きHKDF (100,000回反復)
- ソルト形式: クロスオリジン分離のための
${origin}|${liffId} - IV生成: 暗号学的に安全なランダム96ビットIV
- 認証: 改ざん検出のための128ビット認証タグ
リプレイ攻撃対策
理論的限界
リプレイ攻撃ウィンドウの理論的最大値は以下によって決定されます:
- トークン有効期限: 180秒(3分)の最大ライフタイム
- タイムスタンプ精度: Unixタイムスタンプ(1秒粒度)
- JTI一意性: 128ビットランダム識別子(2^128の可能な値)
実務上の緩和戦略
短い有効期限ウィンドウ
// トークンは最大180秒で期限切れ const payload = { iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 180, // 最大3分 jti: crypto.getRandomValues(new Uint8Array(16)) // 128ビットランダムID };オリジンバインディング
// キーは特定のオリジンにバインドされる const salt = `${window.location.origin}|${liffId}`; const key = await deriveKey(passphrase, salt);ワンタイム使用セマンティクス
// トークンは使用後に消費され、URLがクリーンアップされる window.history.replaceState({}, '', cleanUrl); window.name = ''; // フォールバックデータをクリア
実世界の攻撃シナリオ
- ネットワーク傍受: HTTPS要件とオリジンバインディングにより緩和
- URL共有: トークンは迅速に期限切れし、特定のオリジンにバインドされる
- ブラウザ履歴: URLは処理後即座にクリーンアップされる
- クロスサイト使用: 暗号化オリジンバインディングにより防止
クロスオリジン保護
// 異なるオリジンは異なる暗号化キーを生成する
const key1 = await deriveKey('passphrase', 'https://app1.com|liff-id');
const key2 = await deriveKey('passphrase', 'https://app2.com|liff-id');
// key1 ≠ key2 - トークンはオリジン間で使用できない📚 API ドキュメント
コアインターフェース
interface KiroLiffAuth {
init(opts: InitOptions): Promise<void>;
run(params?: RunParams): Promise<AuthResult>;
}ファクトリ関数
function createLiffAuth(liff?: LiffLike): LiffAuthオプションのLIFF SDK依存性注入を使用して新しいKiroLiffAuthインスタンスを作成します。
パラメータ:
liff(オプション): テストや高度な使用例のためのカスタムLIFF SDKインスタンス
戻り値: 初期化準備完了のKiroLiffAuthインスタンス
初期化
await auth.init(options: InitOptions): Promise<void>設定オプションで認証ユーティリティを初期化します。
パラメータ:
options.liffId(必須): LINE Developers ConsoleからのLIFFアプリケーションIDoptions.cipherPassphrase(オプション): ステートトークン暗号化のパスフレーズ(本番環境では必須)options.decodeIdToken(オプション、デフォルト:true): ユーザーIDのためにIDトークンをデコードするかどうかoptions.maxStateBytes(オプション、デフォルト:512): ステートトークンの最大サイズ(バイト)options.useWindowName(オプション、デフォルト:true): window.nameフォールバック機能を有効にするかどうか
認証実行
const result = await auth.run(params?: RunParams): Promise<AuthResult>オプションのパラメータで認証フローを実行します。
パラメータ:
params(オプション): 認証を通じて永続化するパラメータ
戻り値: 認証結果に解決されるPromise
型定義
// 認証を通じて永続化できるパラメータ
type RunParams = Record<string, string | number | boolean | null | undefined>;
// 認証結果
interface AuthResult {
lineUserId: string; // 認証からのLINEユーザーID
params: RunParams; // 復元されたパラメータ
}
// 設定オプション
interface InitOptions {
liffId: string; // 必須: LIFFアプリケーションID
decodeIdToken?: boolean; // デフォルト: true
cipherPassphrase?: string; // 本番環境推奨
maxStateBytes?: number; // デフォルト: 512
useWindowName?: boolean; // デフォルト: true
}⚙️ 設定オプション
必須設定
await auth.init({
liffId: 'your-liff-id' // LINE Developers Consoleから取得
});本番環境設定
await auth.init({
liffId: 'your-liff-id',
cipherPassphrase: 'your-strong-production-passphrase', // 🚨 本番環境では必須
decodeIdToken: true, // より信頼性の高いユーザー識別のためIDトークンを使用
maxStateBytes: 1024, // より大きなパラメータセットが必要な場合は増加
useWindowName: true // 信頼性のためフォールバックを有効に保つ
});開発環境設定
await auth.init({
liffId: 'your-dev-liff-id',
// cipherPassphrase デバッグを簡単にするため省略(本番環境では非推奨)
maxStateBytes: 2048, // 開発テスト用により大きな制限
useWindowName: true
});パラメータガイドライン
サポートされる型
const params = {
// ✅ サポートされる型
stringValue: 'hello world',
numberValue: 123.45,
booleanValue: true,
nullValue: null,
undefinedValue: undefined,
// ❌ サポートされない型(無視される)
objectValue: { nested: 'object' },
arrayValue: [1, 2, 3],
functionValue: () => {}
};サイズ最適化
// ❌ 非効率 - 長いキーとブール値
const inefficientParams = {
'veryLongParameterNameThatWastesSpace': true,
'anotherVeryLongParameterName': false
};
// ✅ 効率的 - 短いキーと数値
const efficientParams = {
uid: 123, // 短いキー
admin: 1, // ブールの代わりに数値
active: 0 // true/falseの代わりに0/1
};🚨 エラーハンドリング
例外タイプ
import {
LiffInitError,
LiffLoginCancelled,
StateTooLargeError,
TokenInvalidError,
TokenExpiredError
} from 'kiro-liff-auth';包括的なエラーハンドリング
try {
const auth = createLiffAuth();
await auth.init({ liffId: 'your-liff-id', cipherPassphrase: 'secret' });
const result = await auth.run(params);
console.log('成功:', result);
} catch (error) {
if (error instanceof LiffInitError) {
// LIFF SDK初期化失敗
console.error('LIFF初期化失敗:', error.message);
// ユーザーフレンドリーなエラーメッセージを表示
showError('LINE認証の初期化に失敗しました。もう一度お試しください。');
} else if (error instanceof LiffLoginCancelled) {
// ユーザーがログインをキャンセルまたはリダイレクトされた
console.log('ユーザーによりログインがキャンセルされました');
// リトライボタンを表示
showRetryButton();
} else if (error instanceof StateTooLargeError) {
// URL送信にはパラメータが大きすぎる
console.error('パラメータが大きすぎます:', error.message);
// パラメータ最適化を提案
showError('処理するデータが多すぎます。より少ないパラメータでお試しください。');
} else if (error instanceof TokenExpiredError) {
// 認証トークンが期限切れ(180秒超過)
console.error('認証が期限切れです:', error.message);
// 認証フローを再開
restartAuthentication();
} else if (error instanceof TokenInvalidError) {
// トークンが破損または改ざんされた
console.error('無効な認証トークン:', error.message);
// キャッシュされたデータをクリアして再開
clearCacheAndRestart();
} else {
// 予期しないエラー
console.error('予期しないエラー:', error);
showError('予期しないエラーが発生しました。もう一度お試しください。');
}
}Error Recovery Strategies
class AuthenticationManager {
private maxRetries = 3;
private retryCount = 0;
async authenticateWithRetry(params: RunParams): Promise<AuthResult> {
while (this.retryCount < this.maxRetries) {
try {
const auth = createLiffAuth();
await auth.init({ liffId: this.liffId, cipherPassphrase: this.passphrase });
return await auth.run(params);
} catch (error) {
this.retryCount++;
if (error instanceof LiffLoginCancelled) {
// Don't retry user cancellation
throw error;
}
if (error instanceof StateTooLargeError) {
// Try with compressed parameters
params = this.compressParameters(params);
continue;
}
if (error instanceof TokenExpiredError && this.retryCount < this.maxRetries) {
// Wait and retry for expired tokens
await this.delay(1000);
continue;
}
throw error;
}
}
throw new Error('Authentication failed after maximum retries');
}
private compressParameters(params: RunParams): RunParams {
const compressed: RunParams = {};
for (const [key, value] of Object.entries(params)) {
// Shorten keys and convert booleans to numbers
const shortKey = key.length > 5 ? key.substring(0, 5) : key;
compressed[shortKey] = typeof value === 'boolean' ? (value ? 1 : 0) : value;
}
return compressed;
}
private delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
}🔒 セキュリティベストプラクティス
本番デプロイメントチェックリスト
[ ] 強力な暗号化パスフレーズの設定
// ✅ 本番環境では強力でユニークなパスフレーズを使用 cipherPassphrase: 'your-unique-production-passphrase-2024' // ❌ 弱いまたはデフォルトのパスフレーズは絶対に使用しない cipherPassphrase: 'password123' // これはダメ[ ] HTTPS のみ使用
// LIFF エンドポイント URL が HTTPS を使用していることを確認 // https://your-app.com/auth (✅) // http://your-app.com/auth (❌)[ ] LIFF 設定の検証
// LINE Developers Console で LIFF アプリ設定を確認 // - 正しいエンドポイント URL // - 適切なスコープ設定 // - 有効なドメイン設定[ ] 適切なエラーハンドリングの実装
// 常にすべての例外タイプを処理 // ユーザーフレンドリーなエラーメッセージを提供 // 適切な場所でリトライ機能を実装[ ] 認証フローの監視
// 監視のため認証イベントをログ出力 console.log('認証開始', { timestamp: Date.now() }); console.log('認証完了', { userId: result.lineUserId });
パラメータセキュリティガイドライン
// ✅ 安全なパラメータ
const safeParams = {
userId: 123, // 機密でない識別子
returnUrl: '/dashboard', // 公開ルート
theme: 'dark', // UI設定
timestamp: Date.now() // 公開タイムスタンプ
};
// ⚠️ 機密パラメータ(強力なパスフレーズで暗号化)
const sensitiveParams = {
sessionToken: 'abc123', // 暗号化が必要
apiKey: 'secret-key', // 暗号化が必要
personalData: 'sensitive-info' // 暗号化が必要
};
// ❌ 絶対に含めてはいけないもの
const dangerousParams = {
password: 'user-password', // パスワードは絶対に送信しない
creditCard: '1234-5678-9012', // 金融データは絶対に送信しない
ssn: '123-45-6789' // 個人識別情報は絶対に送信しない
};パスフレーズ管理
// ✅ 環境ベースの設定
const cipherPassphrase = process.env.KIRO_LIFF_PASSPHRASE;
if (!cipherPassphrase && process.env.NODE_ENV === 'production') {
throw new Error('本番環境では KIRO_LIFF_PASSPHRASE が必要です');
}
// ✅ 環境ごとに異なるパスフレーズ
const getPassphrase = () => {
switch (process.env.NODE_ENV) {
case 'production':
return process.env.KIRO_LIFF_PROD_PASSPHRASE;
case 'staging':
return process.env.KIRO_LIFF_STAGING_PASSPHRASE;
default:
return process.env.KIRO_LIFF_DEV_PASSPHRASE || 'dev-passphrase';
}
};🔧 高度な使用方法
カスタム LIFF SDK 統合
// テストやカスタム LIFF 実装用
import { createLiffAuth } from 'liff-param-persist';
const customLiff = {
init: async (config) => { /* カスタム実装 */ },
isLoggedIn: () => { /* カスタム実装 */ },
login: (options) => { /* カスタム実装 */ },
getProfile: async () => { /* カスタム実装 */ },
getIDToken: () => { /* カスタム実装 */ }
};
const auth = createLiffAuth(customLiff);
await auth.init({ liffId: 'your-liff-id' });Multiple Authentication Instances
// Different LIFF apps or configurations
const userAuth = createLiffAuth();
await userAuth.init({
liffId: 'user-liff-id',
cipherPassphrase: 'user-passphrase'
});
const adminAuth = createLiffAuth();
await adminAuth.init({
liffId: 'admin-liff-id',
cipherPassphrase: 'admin-passphrase'
});
// Use independently
const userResult = await userAuth.run({ role: 'user' });
const adminResult = await adminAuth.run({ role: 'admin' });Parameter Preprocessing
class ParameterManager {
static optimize(params: RunParams): RunParams {
const optimized: RunParams = {};
for (const [key, value] of Object.entries(params)) {
// Skip null/undefined values
if (value === null || value === undefined) continue;
// Shorten common keys
const shortKey = this.shortenKey(key);
// Convert booleans to numbers for better compression
const optimizedValue = typeof value === 'boolean' ? (value ? 1 : 0) : value;
optimized[shortKey] = optimizedValue;
}
return optimized;
}
private static shortenKey(key: string): string {
const keyMap: Record<string, string> = {
'userId': 'uid',
'returnUrl': 'ret',
'isAdmin': 'adm',
'timestamp': 'ts'
};
return keyMap[key] || key;
}
static restore(params: RunParams): RunParams {
const restored: RunParams = {};
const reverseKeyMap: Record<string, string> = {
'uid': 'userId',
'ret': 'returnUrl',
'adm': 'isAdmin',
'ts': 'timestamp'
};
for (const [key, value] of Object.entries(params)) {
const originalKey = reverseKeyMap[key] || key;
// Convert numbers back to booleans for specific keys
if (['isAdmin', 'adm'].includes(key) && typeof value === 'number') {
restored[originalKey] = value === 1;
} else {
restored[originalKey] = value;
}
}
return restored;
}
}
// Usage
const originalParams = { userId: 123, isAdmin: true, returnUrl: '/dashboard' };
const optimizedParams = ParameterManager.optimize(originalParams);
const auth = createKiroLiffAuth();
await auth.init({ liffId: 'your-liff-id' });
const result = await auth.run(optimizedParams);
const restoredParams = ParameterManager.restore(result.params);🐛 トラブルシューティング
よくある問題と解決策
1. "LIFF SDK not available"
問題: LIFF SDK が読み込まれていないか、グローバルにアクセスできない。
解決策:
// ✅ コードの前に LIFF SDK が読み込まれていることを確認
<script src="https://static.line-scdn.net/liff/edge/2/sdk.js"></script>
<script>
// ここにあなたのコード - LIFF が利用可能になりました
</script>
// ✅ またはカスタム LIFF インスタンスを提供
const auth = createLiffAuth(customLiffInstance);2. "Token has expired"
問題: 認証中に180秒以上経過した。
解決策:
// ✅ リトライロジックを実装
try {
const result = await auth.run(params);
} catch (error) {
if (error instanceof TokenExpiredError) {
// 単純にリトライ - ユーザーは既に認証済みの可能性が高い
const result = await auth.run(params);
}
}3. "State size exceeds limit"
問題: URL送信にはパラメータが大きすぎる。
解決策:
// ✅ パラメータを最適化
const optimizedParams = {
uid: 123, // より短いキー
admin: 1, // ブールの代わりに数値
ret: '/dash' // 省略された値
};
// ✅ 必要に応じて制限を増加
await auth.init({
liffId: 'your-liff-id',
maxStateBytes: 1024 // デフォルトの512から増加
});4. "Invalid token" エラー
問題: トークンの破損またはクロスオリジン使用。
解決策:
// ✅ LIFF アプリ設定を確認
// - LINE Developers Console の正しいエンドポイント URL
// - HTTPS の使用
// - 一致するドメイン設定
// ✅ ブラウザ拡張機能やプロキシをチェック
// 一部のブラウザ拡張機能は URL を変更する可能性があります
// ✅ 一貫したパスフレーズを確保
const passphrase = process.env.KIRO_LIFF_PASSPHRASE;
// 暗号化と復号化に同じパスフレーズを使用する必要があります5. Parameters not restored correctly
Problem: Parameters lost or corrupted during authentication.
Solutions:
// ✅ Check parameter types
const supportedParams = {
string: 'value',
number: 123,
boolean: true,
null: null,
undefined: undefined
};
// ❌ Unsupported types are ignored
const unsupportedParams = {
object: { nested: 'value' }, // Will be ignored
array: [1, 2, 3], // Will be ignored
function: () => {} // Will be ignored
};
// ✅ Enable fallback mechanism
await auth.init({
liffId: 'your-liff-id',
useWindowName: true // Enables fallback (default)
});Debug Mode
// Enable debug logging
const auth = createLiffAuth();
await auth.init({ liffId: 'your-liff-id' });
// Monitor authentication flow
console.log('Starting authentication...');
try {
const result = await auth.run(params);
console.log('Authentication successful:', result);
} catch (error) {
console.error('Authentication failed:', error);
console.error('Error details:', {
name: error.name,
message: error.message,
code: error.code,
stack: error.stack
});
}Network Debugging
// Monitor network requests
window.addEventListener('beforeunload', () => {
console.log('Page unloading - check if this is expected');
});
// Check LIFF initialization
if (typeof liff !== 'undefined') {
console.log('LIFF SDK available');
console.log('LIFF version:', liff.getVersion?.());
} else {
console.error('LIFF SDK not available');
}🎮 デモアプリケーション
完全な Next.js デモアプリケーションが demo/ ディレクトリに含まれています。以下を紹介しています:
- 3行統合パターン
- リアルタイムパラメータテスト
- エラーハンドリング例
- セキュリティ機能のデモンストレーション
- 本番デプロイメントパターン
デモの実行
cd demo
npm install
cp .env.local.example .env.local
# .env.local を編集して LIFF ID を設定
npm run devhttp://localhost:3000 にアクセスしてデモを確認してください。
📄 ライセンス
MIT ライセンス - 詳細は LICENSE ファイルを参照してください。
🤝 コントリビューション
コントリビューションを歓迎します!行動規範とプルリクエストの提出プロセスの詳細については、コントリビューションガイド をお読みください。
📞 サポート
- ドキュメント: 完全な API ドキュメント
- 問題報告: GitHub Issues
- セキュリティ: セキュリティ問題は [email protected] に報告してください
- コミュニティ: ディスカッション
LINE 開発者コミュニティのために ❤️ で作成
