diff --git a/.changeset/auth-refresh.md b/.changeset/auth-refresh.md new file mode 100644 index 0000000..f4782d7 --- /dev/null +++ b/.changeset/auth-refresh.md @@ -0,0 +1,12 @@ +--- +"@9c5s/node-tcnet": minor +--- + +feat: TCNASDP認証の自動リフレッシュ機能を追加 + +Bridgeの認証タイムアウト(~100秒)を回避するため、認証シーケンスを定期的に再実行してLICENSE: EXTを維持する。 + +- `AuthState`型に`"refreshing"`を追加 +- `TCNetConfiguration`に`autoReauth`(デフォルト: true)と`reauthInterval`(デフォルト: 60000ms)を追加 +- 公開`reauth()`メソッドを追加(手動リフレッシュ用、single-flight保証付き) +- `reauthenticated`/`reauthFailed`イベントを追加 diff --git a/docs/wiki/Implementation-Status.md b/docs/wiki/Implementation-Status.md index 4f64086..f7db301 100644 --- a/docs/wiki/Implementation-Status.md +++ b/docs/wiki/Implementation-Status.md @@ -77,14 +77,34 @@ CUE/BeatGrid/BigWaveForm/Artwork等のデータ取得に必要なTCNASDP認証 | `none` | 認証未開始 | | `pending` | 認証シーケンス送信済み、応答待ち | | `authenticated` | 認証成功 | +| `refreshing` | 認証リフレッシュ中 (再認証シーケンスを実行中) | | `failed` | 認証失敗 | -### イベント +### 認証イベント | イベント | 説明 | |----------|------| | `authenticated` | TCNASDP認証が成功した時に発火する | | `authFailed` | TCNASDP認証が失敗した時に発火する | +| `reauthenticated` | 認証リフレッシュが成功した時に発火する | +| `reauthFailed` | 認証リフレッシュが失敗した時に発火する (引数: `Error`) | + +### 認証の自動リフレッシュ + +Bridgeは認証セッションに約100秒の有効期限を設けている。`autoReauth`を有効にすると(デフォルトで有効)、ライブラリが自動的に認証を更新して`LICENSE: EXT`を維持する。 + +```typescript +const config = new TCNetConfiguration(); +config.xteaCiphertext = "your-xtea-ciphertext"; +config.autoReauth = true; // デフォルト: true +config.reauthInterval = 60_000; // デフォルト: 60000ms (60秒) +``` + +手動で認証を更新する場合は`reauth()`メソッドを使用する。 + +```typescript +await client.reauth(); +``` ## 関連ページ diff --git a/src/auth.ts b/src/auth.ts index bd930ec..3028874 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -21,7 +21,7 @@ export const DATA_HASH = 0xc688a0af; * TCNASDP認証の状態を表す型 * @category Auth */ -export type AuthState = "none" | "pending" | "authenticated" | "failed"; +export type AuthState = "none" | "pending" | "authenticated" | "refreshing" | "failed"; /** * FNV-1a Int32変種ハッシュ関数 diff --git a/src/tcnet.ts b/src/tcnet.ts index 790bd1d..7754935 100644 --- a/src/tcnet.ts +++ b/src/tcnet.ts @@ -10,6 +10,7 @@ import { generateAuthPayload, type AuthState } from "./auth"; const TCNET_BROADCAST_PORT = 60000; const TCNET_TIMESTAMP_PORT = 60001; const AUTH_RESPONSE_TIMEOUT = 5000; +const AUTH_REFRESH_TIMEOUT = 100_000; type STORED_REQUEST = { resolve: (value: nw.TCNetDataPacket | PromiseLike) => void; @@ -54,6 +55,15 @@ export class TCNetConfiguration { switchRetryInterval = 1000; /** XTEA暗号文 (16桁hex文字列)。設定時はTCNASDP認証を実行する。環境変数 TCNET_XTEA_CIPHERTEXT で上書き可能 */ xteaCiphertext?: string = process.env.TCNET_XTEA_CIPHERTEXT; + /** 認証の自動リフレッシュを有効にするかどうか。xteaCiphertext設定時、初回認証成功後にタイマーが起動する */ + autoReauth = true; + /** + * 自動リフレッシュの実行間隔 (ミリ秒) + * Bridgeの認証タイムアウト(約100秒)より短く設定する必要がある。 + * 10000ms未満に設定された場合は自動リフレッシュが有効にならない。 + * デフォルト60000ms(60秒)はv3a実機テストで240秒間のLICENSE維持が実証された値である + */ + reauthInterval = 60_000; } /** @@ -94,6 +104,8 @@ export class TCNetClient extends EventEmitter { private sessionToken: number | null = null; private authTimeoutId: NodeJS.Timeout | null = null; private bridgeIsWindows: boolean | null = null; + private reauthIntervalId: NodeJS.Timeout | null = null; + private reauthPromise: Promise | null = null; /** * TCNetClientを初期化する @@ -239,6 +251,12 @@ export class TCNetClient extends EventEmitter { this._selectedAdapter = null; this.server = null; this.detectingAdapter = false; + this.stopAutoReauth(); + // reauthPromise進行中の場合、executeReauthのリスナーが + // authFailedを捕捉してPromiseをrejectできるようにする + if (this.reauthPromise) { + this.emit("authFailed"); + } this.resetAuthSession(); if (this.connectTimeoutId) { clearTimeout(this.connectTimeoutId); @@ -894,6 +912,111 @@ export class TCNetClient extends EventEmitter { return !!ct && /^[0-9a-f]{16}$/i.test(ct); } + /** 自動再認証タイマーを起動する */ + private startAutoReauth(): void { + if (!this.connected || this._authState !== "authenticated") return; + if (!this.config.autoReauth || this.config.reauthInterval < 10_000) return; + if (this.reauthIntervalId) return; + this.reauthIntervalId = setInterval(() => { + this.performReauth().catch((err) => { + const error = err instanceof Error ? err : new Error(String(err)); + this.log?.error(error); + }); + }, this.config.reauthInterval); + this.reauthIntervalId.unref(); + } + + /** 自動再認証タイマーを停止する */ + private stopAutoReauth(): void { + if (this.reauthIntervalId) { + clearInterval(this.reauthIntervalId); + this.reauthIntervalId = null; + } + } + + /** + * 再認証を実行する (内部用) + * authenticated状態でのみ動作する。single-flight保証付き。 + * @param timeoutMs - 認証タイムアウト (ms) + */ + private async performReauth(timeoutMs: number = AUTH_REFRESH_TIMEOUT): Promise { + if (this._authState !== "authenticated" || this.reauthPromise) return; + this.reauthPromise = this.executeReauth(timeoutMs); + try { + await this.reauthPromise; + } finally { + this.reauthPromise = null; + } + } + + /** + * 再認証の実行本体 + * 認証セッションのうち必要な状態(sessionToken, authTimeoutId)のみリセットし、 + * Bridgeからのtoken再送とauthenticated/authFailedイベントを待って完了判定する。 + * resetAuthSessionは呼ばない(bridgeIsWindowsキャッシュを保持するため)。 + * sendAuthSequenceも呼ばない(sessionToken=nullガードでresetAuthSessionが + * 再実行されrefreshing状態が失われるため)。 + * @param timeoutMs - 認証タイムアウト (ms) + */ + private async executeReauth(timeoutMs: number = AUTH_REFRESH_TIMEOUT): Promise { + let cleanup: (() => void) | undefined; + try { + // resetAuthSessionの代わりに必要な状態のみ個別リセットする + // bridgeIsWindowsキャッシュを保持して毎回のOS判定pingを回避する + // (Bridge OSはセッション中に変わらない) + this.sessionToken = null; + if (this.authTimeoutId) { + clearTimeout(this.authTimeoutId); + this.authTimeoutId = null; + } + // handleAuthPacketはcmd=1受理条件に"refreshing"を含むため、 + // この状態でもBridgeからのtoken受信を正常に処理できる + this._authState = "refreshing"; + + // リスナーを登録してBridgeからのtoken再発行を待つ + // sendAuthSequenceは呼ばない: sessionToken=nullのガードでresetAuthSessionが + // 再実行されrefreshing状態が失われるため。Bridgeは定期的なOptInブロードキャスト + // 経由でtoken(AppData cmd=1)を再送するので、明示的なhello送信は不要である + const authPromise = new Promise((resolve, reject) => { + const onAuth = (): void => { + doCleanup(); + resolve(); + }; + const onFail = (): void => { + doCleanup(); + reject(new Error("Reauth failed")); + }; + const timer = setTimeout(() => { + doCleanup(); + reject(new Error("Reauth timeout")); + }, timeoutMs); + timer.unref(); + const doCleanup = (): void => { + this.removeListener("authenticated", onAuth); + this.removeListener("authFailed", onFail); + clearTimeout(timer); + }; + cleanup = doCleanup; + this.once("authenticated", onAuth); + this.once("authFailed", onFail); + }); + + await authPromise; + this.emit("reauthenticated"); + } catch (err) { + cleanup?.(); + // タイムアウト等で失敗した場合、"refreshing"のままだと + // ユーザーが永遠に再認証中と誤認する。"failed"に戻すことで + // handleAuthPacketがBridgeからのtoken再送を受理して自然回復できる + if (this._authState === "refreshing") { + this.sessionToken = null; + this._authState = "failed"; + } + this.emit("reauthFailed", err instanceof Error ? err : new Error(String(err))); + throw err; + } + } + /** 認証セッションをリセットする (再試行可能な状態に戻す) */ private resetAuthSession(): void { this._authState = "none"; @@ -974,7 +1097,7 @@ export class TCNetClient extends EventEmitter { if ( packet.cmd === 1 && this.sessionToken === null && - (this._authState === "none" || this._authState === "failed") + (this._authState === "none" || this._authState === "failed" || this._authState === "refreshing") ) { this.sessionToken = packet.token; this._authState = "pending"; @@ -1006,6 +1129,7 @@ export class TCNetClient extends EventEmitter { this._authState = "authenticated"; this.log?.debug("TCNASDP authentication succeeded"); this.emit("authenticated"); + this.startAutoReauth(); } else if (b0 === 0xff && b1 === 0xff && b2 === 0x0d) { if (this.authTimeoutId) { clearTimeout(this.authTimeoutId); @@ -1019,6 +1143,28 @@ export class TCNetClient extends EventEmitter { } } + /** + * 認証を手動でリフレッシュする + * + * 自動リフレッシュが無効化されている場合、または任意のタイミングで認証を + * 更新したい場合に呼び出す。既にリフレッシュ中の場合は進行中のPromiseを返す。 + * @param timeoutMs - Bridgeからの応答待ちタイムアウト (デフォルト100000ms) + * @returns 認証完了時にresolveする + * @throws {Error} xteaCiphertext未設定、未認証、接続未確立の場合 + */ + public async reauth(timeoutMs: number = AUTH_REFRESH_TIMEOUT): Promise { + if (!this.hasValidXteaCiphertext()) { + throw new Error("xteaCiphertext not configured"); + } + if (this.reauthPromise) { + return this.reauthPromise; + } + if (this._authState !== "authenticated") { + throw new Error("Cannot reauth: not authenticated"); + } + return this.performReauth(timeoutMs); + } + /** * データリクエストをブロードキャストで送信する * diff --git a/tests/auth.test.ts b/tests/auth.test.ts index 12056c2..47f8790 100644 --- a/tests/auth.test.ts +++ b/tests/auth.test.ts @@ -268,6 +268,24 @@ class AuthTestClient extends TCNetClient { public clearXteaCiphertext(): void { (this as any).config.xteaCiphertext = undefined; } + public getReauthPromise(): Promise | null { + return (this as any).reauthPromise; + } + public async callPerformReauth(): Promise { + return (this as any).performReauth(); + } + public async callReauth(timeoutMs?: number): Promise { + return (this as any).reauth(timeoutMs); + } + public callStartAutoReauth(): void { + (this as any).startAutoReauth(); + } + public callStopAutoReauth(): void { + (this as any).stopAutoReauth(); + } + public getReauthIntervalId(): NodeJS.Timeout | null { + return (this as any).reauthIntervalId; + } } function createAppDataPacket(cmd: number, token: number): TCNetApplicationDataPacket { @@ -433,6 +451,52 @@ describe("handleAuthPacket", () => { expect(client.authenticationState).toBe("none"); expect(client.getSessionToken()).toBeNull(); }); + + it("初回認証成功時にautoReauthタイマーが起動する", () => { + const client = new AuthTestClient(); + (client as any).connected = true; + (client as any).config.autoReauth = true; + (client as any).config.reauthInterval = 60_000; + client.setAuthState("pending"); + + client.callHandleAuth(createErrorPacket(0xff, 0xff, 0xff)); + + expect(client.authenticationState).toBe("authenticated"); + expect(client.getReauthIntervalId()).not.toBeNull(); + client.callStopAutoReauth(); // クリーンアップ + }); + + it("autoReauth=falseの場合タイマーは起動しない", () => { + const client = new AuthTestClient(); + (client as any).connected = true; + (client as any).config.autoReauth = false; + client.setAuthState("pending"); + + client.callHandleAuth(createErrorPacket(0xff, 0xff, 0xff)); + + expect(client.authenticationState).toBe("authenticated"); + expect(client.getReauthIntervalId()).toBeNull(); + }); + + it("再認証成功時にstartAutoReauthが重複呼び出しされてもタイマーが1つのまま", () => { + const client = new AuthTestClient(); + (client as any).connected = true; + (client as any).config.autoReauth = true; + (client as any).config.reauthInterval = 60_000; + client.setAuthState("pending"); + + // 初回認証成功 → タイマー起動 + client.callHandleAuth(createErrorPacket(0xff, 0xff, 0xff)); + const firstTimerId = client.getReauthIntervalId(); + expect(firstTimerId).not.toBeNull(); + + // 再認証成功 (再びauthenticatedパスを通る) → タイマーは既存のまま + client.setAuthState("pending"); + client.callHandleAuth(createErrorPacket(0xff, 0xff, 0xff)); + expect(client.getReauthIntervalId()).toBe(firstTimerId); + + client.callStopAutoReauth(); // クリーンアップ + }); }); // sendAuthSequenceをモックせずに状態リセット動作を検証するヘルパー @@ -804,3 +868,246 @@ describe("sendAuthSequence XTEA暗号文バイトリバース", () => { expect(client.getBridgeIsWindows()).toBeNull(); }); }); + +describe("performReauth", () => { + it("authenticated以外の状態では何もしない", async () => { + const client = new AuthTestClient(); + client.setAuthState("none"); + await client.callPerformReauth(); + expect(client.authenticationState).toBe("none"); + }); + + it("authenticatedからresetAuthSessionで初期化されauthenticatedイベントで復帰する", async () => { + const client = new AuthTestClient(); + client.setAuthState("authenticated"); + + const promise = client.callPerformReauth(); + // executeReauthがresetAuthSession後にstate=refreshingを設定する + // sendAuthSequenceのawaitまで同期実行されるため、ここで観測可能 + expect(client.authenticationState).toBe("refreshing"); + + // authenticatedイベントをシミュレートして再認証完了 + setTimeout(() => { + client.setAuthState("authenticated"); + client.emit("authenticated"); + }, 10); + await promise; + expect(client.authenticationState).toBe("authenticated"); + }); + + it("single-flight: reauthPromiseが存在する間は2回目がskipされる", async () => { + const client = new AuthTestClient(); + client.setAuthState("authenticated"); + + const p1 = client.callPerformReauth(); + // reauthPromiseが設定済み、かつauthStateがrefreshingに遷移済みのため2回目はskip + const p2 = client.callPerformReauth(); + await expect(p2).resolves.toBeUndefined(); + + setTimeout(() => { + client.setAuthState("authenticated"); + client.emit("authenticated"); + }, 10); + await p1; + }); + + it("reauthenticatedイベントが成功時に発火する", async () => { + const client = new AuthTestClient(); + client.setAuthState("authenticated"); + const handler = vi.fn(); + client.on("reauthenticated", handler); + + const promise = client.callPerformReauth(); + // 手動で認証成功をシミュレート + setTimeout(() => { + client.setAuthState("authenticated"); + client.emit("authenticated"); + }, 10); + await promise; + + expect(handler).toHaveBeenCalledTimes(1); + }); + + it("reauthFailedイベントが失敗時に発火する", async () => { + const client = new AuthTestClient(); + client.setAuthState("authenticated"); + const handler = vi.fn(); + client.on("reauthFailed", handler); + + const promise = client.callPerformReauth(); + // 手動で認証失敗をシミュレート + setTimeout(() => client.emit("authFailed"), 10); + await expect(promise).rejects.toThrow(); + + expect(handler).toHaveBeenCalledTimes(1); + }); + + it("失敗後にauthStateがfailedに戻る", async () => { + const client = new AuthTestClient(); + client.setAuthState("authenticated"); + + const promise = client.callPerformReauth(); + expect(client.authenticationState).toBe("refreshing"); + + // 手動で認証失敗をシミュレート + setTimeout(() => client.emit("authFailed"), 10); + await expect(promise).rejects.toThrow(); + + // タイムアウト等の失敗でrefreshingのまま残らずfailedに戻る + // (authFailedパスではhandleAuthPacketが先にfailedに設定するが、 + // disconnect等でrefreshingのままcatchに入った場合の回復を検証) + expect(client.authenticationState).toBe("failed"); + }); +}); + +describe("autoReauth タイマー", () => { + it("startAutoReauthでタイマーが起動する", () => { + const client = new AuthTestClient(); + (client as any).connected = true; + client.setAuthState("authenticated"); + (client as any).config.autoReauth = true; + (client as any).config.reauthInterval = 60_000; + + client.callStartAutoReauth(); + expect(client.getReauthIntervalId()).not.toBeNull(); + client.callStopAutoReauth(); + }); + + it("未接続状態ではタイマーが起動しない", () => { + const client = new AuthTestClient(); + client.setAuthState("authenticated"); + (client as any).config.autoReauth = true; + (client as any).config.reauthInterval = 60_000; + // connectedがfalse(デフォルト)の場合 + expect((client as any).connected).toBe(false); + + client.callStartAutoReauth(); + expect(client.getReauthIntervalId()).toBeNull(); + }); + + it("autoReauth=falseではタイマーが起動しない", () => { + const client = new AuthTestClient(); + (client as any).connected = true; + client.setAuthState("authenticated"); + (client as any).config.autoReauth = false; + + client.callStartAutoReauth(); + expect(client.getReauthIntervalId()).toBeNull(); + }); + + it("reauthInterval < 10000ではタイマーが起動しない", () => { + const client = new AuthTestClient(); + (client as any).connected = true; + client.setAuthState("authenticated"); + (client as any).config.autoReauth = true; + (client as any).config.reauthInterval = 9999; + + client.callStartAutoReauth(); + expect(client.getReauthIntervalId()).toBeNull(); + }); + + it("reauthInterval = 10000 (下限ちょうど) ではタイマーが起動する", () => { + const client = new AuthTestClient(); + (client as any).connected = true; + client.setAuthState("authenticated"); + (client as any).config.autoReauth = true; + (client as any).config.reauthInterval = 10_000; + + client.callStartAutoReauth(); + expect(client.getReauthIntervalId()).not.toBeNull(); + client.callStopAutoReauth(); + }); + + it("stopAutoReauthでタイマーが停止する", () => { + const client = new AuthTestClient(); + (client as any).connected = true; + client.setAuthState("authenticated"); + (client as any).config.autoReauth = true; + (client as any).config.reauthInterval = 60_000; + + client.callStartAutoReauth(); + expect(client.getReauthIntervalId()).not.toBeNull(); + client.callStopAutoReauth(); + expect(client.getReauthIntervalId()).toBeNull(); + }); +}); + +describe("reauth (public API)", () => { + it("未認証状態ではエラーをスローする", async () => { + const client = new AuthTestClient(); + client.setAuthState("none"); + await expect(client.callReauth()).rejects.toThrow("Cannot reauth: not authenticated"); + }); + + it("xteaCiphertext未設定ではエラーをスローする", async () => { + const client = new AuthTestClient(); + client.setAuthState("authenticated"); + client.clearXteaCiphertext(); + await expect(client.callReauth()).rejects.toThrow("xteaCiphertext not configured"); + }); + + it("authenticated状態で呼び出せる", async () => { + const client = new AuthTestClient(); + client.setAuthState("authenticated"); + + const promise = client.callReauth(); + // authenticatedイベントをシミュレート + setTimeout(() => { + client.setAuthState("authenticated"); + client.emit("authenticated"); + }, 10); + await promise; + expect(client.authenticationState).toBe("authenticated"); + }); + + it("single-flight: 進行中のreauthに相乗りする", async () => { + const client = new AuthTestClient(); + client.setAuthState("authenticated"); + + const p1 = client.callReauth(); + const p2 = client.callReauth(); + + // 両方同じPromiseを共有する + setTimeout(() => { + client.setAuthState("authenticated"); + client.emit("authenticated"); + }, 10); + await Promise.all([p1, p2]); + }); +}); + +describe("disconnect時のreauthクリーンアップ", () => { + it("stopAutoReauthが呼ばれタイマーが停止する", () => { + const client = new AuthTestClient(); + (client as any).connected = true; + client.setAuthState("authenticated"); + (client as any).config.autoReauth = true; + (client as any).config.reauthInterval = 60_000; + client.callStartAutoReauth(); + expect(client.getReauthIntervalId()).not.toBeNull(); + + // disconnectSocketsの代わりにstopAutoReauthを直接テスト + // (disconnectSocketsはソケット操作を伴うため単体テストでは呼べない) + client.callStopAutoReauth(); + expect(client.getReauthIntervalId()).toBeNull(); + }); + + it("reauthPromise進行中にdisconnect相当の操作でPromiseがrejectされる", async () => { + const client = new AuthTestClient(); + client.setAuthState("authenticated"); + + // 再認証を開始 (authenticatedイベント待ちの状態にする) + const promise = client.callPerformReauth(); + expect(client.getReauthPromise()).not.toBeNull(); + + // disconnectSocketsの実フローを再現: + // stopAutoReauth → reauthPromise存在時にauthFailed emit → resetAuthSession + client.callStopAutoReauth(); + if (client.getReauthPromise()) { + client.emit("authFailed"); + } + (client as any).resetAuthSession(); + await expect(promise).rejects.toThrow(); + expect(client.getReauthPromise()).toBeNull(); + }); +});