Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/auth-refresh.md
Original file line number Diff line number Diff line change
@@ -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`イベントを追加
22 changes: 21 additions & 1 deletion docs/wiki/Implementation-Status.md
Original file line number Diff line number Diff line change
Expand Up @@ -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();
```

## 関連ページ

Expand Down
2 changes: 1 addition & 1 deletion src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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変種ハッシュ関数
Expand Down
148 changes: 147 additions & 1 deletion src/tcnet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<nw.TCNetDataPacket>) => void;
Expand Down Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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<void> | null = null;

/**
* TCNetClientを初期化する
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<void> {
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<void> {
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<void>((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";
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand All @@ -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<void> {
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);
}

/**
* データリクエストをブロードキャストで送信する
*
Expand Down
Loading
Loading