Skip to content

Commit 2ca8965

Browse files
authored
feat: TCNASDP認証の自動リフレッシュ機能 (#63)
* feat: AuthStateに"refreshing"を追加しTCNetConfigurationを拡張 認証の自動リフレッシュ機能の基盤として、AuthState型に "refreshing"状態を追加し、TCNetConfigurationにautoReauth (デフォルトtrue)とreauthInterval(デフォルト60000ms)を追加する。 * test: 再認証 performReauth のテストを追加 (Red) * feat: performReauth/executeReauthを実装 resetAuthSession + sendAuthSequenceの既存フローに合流する 再認証ロジックを実装する。single-flight保証付き。 executeReauthでresetAuthSession後にstate="refreshing"を設定し、 ユーザーが再認証中を観測可能にする。 handleAuthPacketのcmd=1受理条件にrefreshingを追加する。 * feat: startAutoReauth/stopAutoReauthを実装 認証成功後に自動再認証タイマーを起動する仕組みを追加する。 autoReauth=trueかつreauthInterval>=10000の場合のみ起動する。 * feat: 公開reauth()メソッドを追加 手動で認証をリフレッシュするための公開APIを追加する。 single-flight保証付きで、進行中の再認証にはPromiseを共有する。 * feat: 初回認証成功時にautoReauthタイマーを起動 * feat: disconnect時にautoReauthタイマーを確実に停止 * docs: 認証リフレッシュ機能のドキュメントを追加 * refactor: コードレビュー指摘に基づく改善 - executeReauthのonAuthから冗長な_authState代入を削除 テスト側でhandleAuthPacketの実フローを忠実にシミュレートする - startAutoReauthのsetIntervalにunref()を追加 disconnect忘れ時にプロセス終了をブロックしない * fix: 並列レビュー指摘に基づく5件の修正 - T1 [Critical]: disconnectSocketsでreauthPromise進行中なら authFailedをemitしてPromiseを確実にrejectする - T2 [High]: executeReauthのcatchでリスナー・タイマーを cleanupしsendAuthSequence失敗時のリークを防止する - T3 [Medium]: executeReauth内のsetTimeoutにunref()を追加し disconnect後のプロセス終了ブロックを防止する - T4 [Low]: reauthInterval=10000の境界値テストを追加する - T5 [Low]: reauthFailedイベントの引数仕様をドキュメントに追記する * fix: executeReauthからsendAuthSequence呼び出しを削除 sendAuthSequenceはsessionToken===nullガードでresetAuthSessionを 呼び出すため、executeReauthで設定した"refreshing"状態が"none"に 上書きされていた。実機テストで以下の改善を確認: - "refreshing"状態が正しく観測可能になった - Bridgeのトークン再送が高速化(94s→2s) - 全再認証が成功(修正前は1/3のみ成功) BridgeはOptInブロードキャスト経由でトークンを再送するため、 resetAuthSessionによる状態リセットのみで再認証フローに合流できる。 * chore: 認証リフレッシュ機能のchangesetを追加 * refactor: PRレビュー指摘に基づく3件の改善 - reauth()でreauthPromiseチェックをstate チェックの前に移動し、 再認証中のpending状態でもPromiseを返せるようにする - reauthIntervalのJSDocに10000ms下限の制約を明記する - executeReauthでresetAuthSession()の代わりに必要な状態のみ 個別リセットしbridgeIsWindowsキャッシュを保持する * fix: CodeRabbitレビュー2件の指摘に対応 - startAutoReauthに接続・認証状態ガードを追加 authenticatedリスナー内でdisconnect等が呼ばれた場合に 不要なタイマー起動を防止する - executeReauthのcatchでrefreshing状態をfailedに回復 タイムアウト等の失敗後にauthenticationStateが永遠に refreshingのまま残る問題を修正する * refactor: CodeRabbitレビュー3件目の指摘に対応 - executeReauthのJSDocを実装に合わせて更新する (resetAuthSession/sendAuthSequenceを呼ばない設計の説明) - 否定系テストで意図したガード以外の前提条件を満たすよう修正 (connected/authState設定を追加し、対象ガードのみ検証) * test: performReauthのsingle-flightテストコメントを正確に修正
1 parent b6748a6 commit 2ca8965

5 files changed

Lines changed: 488 additions & 3 deletions

File tree

.changeset/auth-refresh.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@9c5s/node-tcnet": minor
3+
---
4+
5+
feat: TCNASDP認証の自動リフレッシュ機能を追加
6+
7+
Bridgeの認証タイムアウト(~100秒)を回避するため、認証シーケンスを定期的に再実行してLICENSE: EXTを維持する。
8+
9+
- `AuthState`型に`"refreshing"`を追加
10+
- `TCNetConfiguration``autoReauth`(デフォルト: true)と`reauthInterval`(デフォルト: 60000ms)を追加
11+
- 公開`reauth()`メソッドを追加(手動リフレッシュ用、single-flight保証付き)
12+
- `reauthenticated`/`reauthFailed`イベントを追加

docs/wiki/Implementation-Status.md

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,14 +77,34 @@ CUE/BeatGrid/BigWaveForm/Artwork等のデータ取得に必要なTCNASDP認証
7777
| `none` | 認証未開始 |
7878
| `pending` | 認証シーケンス送信済み、応答待ち |
7979
| `authenticated` | 認証成功 |
80+
| `refreshing` | 認証リフレッシュ中 (再認証シーケンスを実行中) |
8081
| `failed` | 認証失敗 |
8182

82-
### イベント
83+
### 認証イベント
8384

8485
| イベント | 説明 |
8586
|----------|------|
8687
| `authenticated` | TCNASDP認証が成功した時に発火する |
8788
| `authFailed` | TCNASDP認証が失敗した時に発火する |
89+
| `reauthenticated` | 認証リフレッシュが成功した時に発火する |
90+
| `reauthFailed` | 認証リフレッシュが失敗した時に発火する (引数: `Error`) |
91+
92+
### 認証の自動リフレッシュ
93+
94+
Bridgeは認証セッションに約100秒の有効期限を設けている。`autoReauth`を有効にすると(デフォルトで有効)、ライブラリが自動的に認証を更新して`LICENSE: EXT`を維持する。
95+
96+
```typescript
97+
const config = new TCNetConfiguration();
98+
config.xteaCiphertext = "your-xtea-ciphertext";
99+
config.autoReauth = true; // デフォルト: true
100+
config.reauthInterval = 60_000; // デフォルト: 60000ms (60秒)
101+
```
102+
103+
手動で認証を更新する場合は`reauth()`メソッドを使用する。
104+
105+
```typescript
106+
await client.reauth();
107+
```
88108

89109
## 関連ページ
90110

src/auth.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export const DATA_HASH = 0xc688a0af;
2121
* TCNASDP認証の状態を表す型
2222
* @category Auth
2323
*/
24-
export type AuthState = "none" | "pending" | "authenticated" | "failed";
24+
export type AuthState = "none" | "pending" | "authenticated" | "refreshing" | "failed";
2525

2626
/**
2727
* FNV-1a Int32変種ハッシュ関数

src/tcnet.ts

Lines changed: 147 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { generateAuthPayload, type AuthState } from "./auth";
1010
const TCNET_BROADCAST_PORT = 60000;
1111
const TCNET_TIMESTAMP_PORT = 60001;
1212
const AUTH_RESPONSE_TIMEOUT = 5000;
13+
const AUTH_REFRESH_TIMEOUT = 100_000;
1314

1415
type STORED_REQUEST = {
1516
resolve: (value: nw.TCNetDataPacket | PromiseLike<nw.TCNetDataPacket>) => void;
@@ -54,6 +55,15 @@ export class TCNetConfiguration {
5455
switchRetryInterval = 1000;
5556
/** XTEA暗号文 (16桁hex文字列)。設定時はTCNASDP認証を実行する。環境変数 TCNET_XTEA_CIPHERTEXT で上書き可能 */
5657
xteaCiphertext?: string = process.env.TCNET_XTEA_CIPHERTEXT;
58+
/** 認証の自動リフレッシュを有効にするかどうか。xteaCiphertext設定時、初回認証成功後にタイマーが起動する */
59+
autoReauth = true;
60+
/**
61+
* 自動リフレッシュの実行間隔 (ミリ秒)
62+
* Bridgeの認証タイムアウト(約100秒)より短く設定する必要がある。
63+
* 10000ms未満に設定された場合は自動リフレッシュが有効にならない。
64+
* デフォルト60000ms(60秒)はv3a実機テストで240秒間のLICENSE維持が実証された値である
65+
*/
66+
reauthInterval = 60_000;
5767
}
5868

5969
/**
@@ -94,6 +104,8 @@ export class TCNetClient extends EventEmitter {
94104
private sessionToken: number | null = null;
95105
private authTimeoutId: NodeJS.Timeout | null = null;
96106
private bridgeIsWindows: boolean | null = null;
107+
private reauthIntervalId: NodeJS.Timeout | null = null;
108+
private reauthPromise: Promise<void> | null = null;
97109

98110
/**
99111
* TCNetClientを初期化する
@@ -239,6 +251,12 @@ export class TCNetClient extends EventEmitter {
239251
this._selectedAdapter = null;
240252
this.server = null;
241253
this.detectingAdapter = false;
254+
this.stopAutoReauth();
255+
// reauthPromise進行中の場合、executeReauthのリスナーが
256+
// authFailedを捕捉してPromiseをrejectできるようにする
257+
if (this.reauthPromise) {
258+
this.emit("authFailed");
259+
}
242260
this.resetAuthSession();
243261
if (this.connectTimeoutId) {
244262
clearTimeout(this.connectTimeoutId);
@@ -894,6 +912,111 @@ export class TCNetClient extends EventEmitter {
894912
return !!ct && /^[0-9a-f]{16}$/i.test(ct);
895913
}
896914

915+
/** 自動再認証タイマーを起動する */
916+
private startAutoReauth(): void {
917+
if (!this.connected || this._authState !== "authenticated") return;
918+
if (!this.config.autoReauth || this.config.reauthInterval < 10_000) return;
919+
if (this.reauthIntervalId) return;
920+
this.reauthIntervalId = setInterval(() => {
921+
this.performReauth().catch((err) => {
922+
const error = err instanceof Error ? err : new Error(String(err));
923+
this.log?.error(error);
924+
});
925+
}, this.config.reauthInterval);
926+
this.reauthIntervalId.unref();
927+
}
928+
929+
/** 自動再認証タイマーを停止する */
930+
private stopAutoReauth(): void {
931+
if (this.reauthIntervalId) {
932+
clearInterval(this.reauthIntervalId);
933+
this.reauthIntervalId = null;
934+
}
935+
}
936+
937+
/**
938+
* 再認証を実行する (内部用)
939+
* authenticated状態でのみ動作する。single-flight保証付き。
940+
* @param timeoutMs - 認証タイムアウト (ms)
941+
*/
942+
private async performReauth(timeoutMs: number = AUTH_REFRESH_TIMEOUT): Promise<void> {
943+
if (this._authState !== "authenticated" || this.reauthPromise) return;
944+
this.reauthPromise = this.executeReauth(timeoutMs);
945+
try {
946+
await this.reauthPromise;
947+
} finally {
948+
this.reauthPromise = null;
949+
}
950+
}
951+
952+
/**
953+
* 再認証の実行本体
954+
* 認証セッションのうち必要な状態(sessionToken, authTimeoutId)のみリセットし、
955+
* Bridgeからのtoken再送とauthenticated/authFailedイベントを待って完了判定する。
956+
* resetAuthSessionは呼ばない(bridgeIsWindowsキャッシュを保持するため)。
957+
* sendAuthSequenceも呼ばない(sessionToken=nullガードでresetAuthSessionが
958+
* 再実行されrefreshing状態が失われるため)。
959+
* @param timeoutMs - 認証タイムアウト (ms)
960+
*/
961+
private async executeReauth(timeoutMs: number = AUTH_REFRESH_TIMEOUT): Promise<void> {
962+
let cleanup: (() => void) | undefined;
963+
try {
964+
// resetAuthSessionの代わりに必要な状態のみ個別リセットする
965+
// bridgeIsWindowsキャッシュを保持して毎回のOS判定pingを回避する
966+
// (Bridge OSはセッション中に変わらない)
967+
this.sessionToken = null;
968+
if (this.authTimeoutId) {
969+
clearTimeout(this.authTimeoutId);
970+
this.authTimeoutId = null;
971+
}
972+
// handleAuthPacketはcmd=1受理条件に"refreshing"を含むため、
973+
// この状態でもBridgeからのtoken受信を正常に処理できる
974+
this._authState = "refreshing";
975+
976+
// リスナーを登録してBridgeからのtoken再発行を待つ
977+
// sendAuthSequenceは呼ばない: sessionToken=nullのガードでresetAuthSessionが
978+
// 再実行されrefreshing状態が失われるため。Bridgeは定期的なOptInブロードキャスト
979+
// 経由でtoken(AppData cmd=1)を再送するので、明示的なhello送信は不要である
980+
const authPromise = new Promise<void>((resolve, reject) => {
981+
const onAuth = (): void => {
982+
doCleanup();
983+
resolve();
984+
};
985+
const onFail = (): void => {
986+
doCleanup();
987+
reject(new Error("Reauth failed"));
988+
};
989+
const timer = setTimeout(() => {
990+
doCleanup();
991+
reject(new Error("Reauth timeout"));
992+
}, timeoutMs);
993+
timer.unref();
994+
const doCleanup = (): void => {
995+
this.removeListener("authenticated", onAuth);
996+
this.removeListener("authFailed", onFail);
997+
clearTimeout(timer);
998+
};
999+
cleanup = doCleanup;
1000+
this.once("authenticated", onAuth);
1001+
this.once("authFailed", onFail);
1002+
});
1003+
1004+
await authPromise;
1005+
this.emit("reauthenticated");
1006+
} catch (err) {
1007+
cleanup?.();
1008+
// タイムアウト等で失敗した場合、"refreshing"のままだと
1009+
// ユーザーが永遠に再認証中と誤認する。"failed"に戻すことで
1010+
// handleAuthPacketがBridgeからのtoken再送を受理して自然回復できる
1011+
if (this._authState === "refreshing") {
1012+
this.sessionToken = null;
1013+
this._authState = "failed";
1014+
}
1015+
this.emit("reauthFailed", err instanceof Error ? err : new Error(String(err)));
1016+
throw err;
1017+
}
1018+
}
1019+
8971020
/** 認証セッションをリセットする (再試行可能な状態に戻す) */
8981021
private resetAuthSession(): void {
8991022
this._authState = "none";
@@ -974,7 +1097,7 @@ export class TCNetClient extends EventEmitter {
9741097
if (
9751098
packet.cmd === 1 &&
9761099
this.sessionToken === null &&
977-
(this._authState === "none" || this._authState === "failed")
1100+
(this._authState === "none" || this._authState === "failed" || this._authState === "refreshing")
9781101
) {
9791102
this.sessionToken = packet.token;
9801103
this._authState = "pending";
@@ -1006,6 +1129,7 @@ export class TCNetClient extends EventEmitter {
10061129
this._authState = "authenticated";
10071130
this.log?.debug("TCNASDP authentication succeeded");
10081131
this.emit("authenticated");
1132+
this.startAutoReauth();
10091133
} else if (b0 === 0xff && b1 === 0xff && b2 === 0x0d) {
10101134
if (this.authTimeoutId) {
10111135
clearTimeout(this.authTimeoutId);
@@ -1019,6 +1143,28 @@ export class TCNetClient extends EventEmitter {
10191143
}
10201144
}
10211145

1146+
/**
1147+
* 認証を手動でリフレッシュする
1148+
*
1149+
* 自動リフレッシュが無効化されている場合、または任意のタイミングで認証を
1150+
* 更新したい場合に呼び出す。既にリフレッシュ中の場合は進行中のPromiseを返す。
1151+
* @param timeoutMs - Bridgeからの応答待ちタイムアウト (デフォルト100000ms)
1152+
* @returns 認証完了時にresolveする
1153+
* @throws {Error} xteaCiphertext未設定、未認証、接続未確立の場合
1154+
*/
1155+
public async reauth(timeoutMs: number = AUTH_REFRESH_TIMEOUT): Promise<void> {
1156+
if (!this.hasValidXteaCiphertext()) {
1157+
throw new Error("xteaCiphertext not configured");
1158+
}
1159+
if (this.reauthPromise) {
1160+
return this.reauthPromise;
1161+
}
1162+
if (this._authState !== "authenticated") {
1163+
throw new Error("Cannot reauth: not authenticated");
1164+
}
1165+
return this.performReauth(timeoutMs);
1166+
}
1167+
10221168
/**
10231169
* データリクエストをブロードキャストで送信する
10241170
*

0 commit comments

Comments
 (0)