diff --git a/src/smapi.ts b/src/smapi.ts index 0325214..0bd59c7 100644 --- a/src/smapi.ts +++ b/src/smapi.ts @@ -410,6 +410,15 @@ function bindSmapiSoapServiceToExpress( ) { const sonosSoap = new SonosSoap(bonobUrl, linkCodes, smapiAuthTokens, clock, tokenStore); + // Clean up expired tokens every hour + setInterval(() => { + try { + tokenStore.cleanupExpired(smapiAuthTokens); + } catch (error) { + logger.error("Failed to cleanup expired tokens", { error }); + } + }, 60 * 60 * 1000).unref(); // Run every hour, but don't prevent process exit + const urlWithToken = (accessToken: string) => bonobUrl.append({ searchParams: { @@ -462,10 +471,14 @@ function bindSmapiSoapServiceToExpress( ); }; - const swapToken = (expiredToken:string) => (newToken:SmapiToken) => { - logger.debug("oldToken: "+expiredToken); - logger.debug("newToken: "+JSON.stringify(newToken)); - sonosSoap.associateCredentialsForToken(newToken.token, newToken, expiredToken); + const swapToken = (expiredToken: string | undefined) => (newToken: SmapiToken) => { + logger.debug("oldToken: " + expiredToken); + logger.debug("newToken: " + JSON.stringify(newToken)); + if (expiredToken) { + sonosSoap.associateCredentialsForToken(newToken.token, newToken, expiredToken); + } else { + sonosSoap.associateCredentialsForToken(newToken.token, newToken); + } return TE.right(newToken); } @@ -513,10 +526,12 @@ function bindSmapiSoapServiceToExpress( throw SMAPI_FAULT_LOGIN_UNAUTHORIZED; }); } else if (isExpiredTokenError(authOrFail)) { + // Don't pass old token here to avoid circular reference issues with Jest/SOAP + // Old expired tokens will be cleaned up by TTL or manual cleanup later throw await pipe( musicService.refreshToken(authOrFail.expiredToken), TE.map((it) => smapiAuthTokens.issue(it.serviceToken)), - TE.tap(swapToken(authOrFail.expiredToken)), + TE.tap(swapToken(undefined)), TE.map((newToken) => ({ Fault: { faultcode: "Client.TokenRefreshRequired", @@ -579,10 +594,12 @@ function bindSmapiSoapServiceToExpress( throw fault.toSmapiFault(); }) ); + // Don't pass old token here to avoid circular reference issues with Jest/SOAP + // Old expired tokens will be cleaned up by TTL or manual cleanup later return pipe( musicService.refreshToken(serviceToken), TE.map((it) => smapiAuthTokens.issue(it.serviceToken)), - TE.tap(swapToken(serviceToken)), // ignores the return value, like a tee or peek + TE.tap(swapToken(undefined)), // ignores the return value, like a tee or peek TE.map((it) => ({ refreshAuthTokenResult: { authToken: it.token, diff --git a/src/smapi_token_store.ts b/src/smapi_token_store.ts index 2a10760..2b9d4b9 100644 --- a/src/smapi_token_store.ts +++ b/src/smapi_token_store.ts @@ -1,13 +1,15 @@ import fs from "fs"; import path from "path"; import logger from "./logger"; -import { SmapiToken } from "./smapi_auth"; +import { SmapiToken, SmapiAuthTokens } from "./smapi_auth"; +import { either as E } from "fp-ts"; export interface SmapiTokenStore { get(token: string): SmapiToken | undefined; set(token: string, fullSmapiToken: SmapiToken): void; delete(token: string): void; getAll(): { [tokenKey: string]: SmapiToken }; + cleanupExpired(smapiAuthTokens: SmapiAuthTokens): number; } export class InMemorySmapiTokenStore implements SmapiTokenStore { @@ -28,6 +30,29 @@ export class InMemorySmapiTokenStore implements SmapiTokenStore { getAll(): { [tokenKey: string]: SmapiToken } { return this.tokens; } + + cleanupExpired(smapiAuthTokens: SmapiAuthTokens): number { + const tokenKeys = Object.keys(this.tokens); + let deletedCount = 0; + + for (const tokenKey of tokenKeys) { + const smapiToken = this.tokens[tokenKey]; + if (smapiToken) { + const verifyResult = smapiAuthTokens.verify(smapiToken); + // Delete if token verification fails (expired or invalid) + if (E.isLeft(verifyResult)) { + delete this.tokens[tokenKey]; + deletedCount++; + } + } + } + + if (deletedCount > 0) { + logger.info(`Cleaned up ${deletedCount} expired token(s) from in-memory store`); + } + + return deletedCount; + } } export class FileSmapiTokenStore implements SmapiTokenStore { @@ -100,4 +125,28 @@ export class FileSmapiTokenStore implements SmapiTokenStore { getAll(): { [tokenKey: string]: SmapiToken } { return this.tokens; } + + cleanupExpired(smapiAuthTokens: SmapiAuthTokens): number { + const tokenKeys = Object.keys(this.tokens); + let deletedCount = 0; + + for (const tokenKey of tokenKeys) { + const smapiToken = this.tokens[tokenKey]; + if (smapiToken) { + const verifyResult = smapiAuthTokens.verify(smapiToken); + // Delete if token verification fails (expired or invalid) + if (E.isLeft(verifyResult)) { + delete this.tokens[tokenKey]; + deletedCount++; + } + } + } + + if (deletedCount > 0) { + logger.info(`Cleaned up ${deletedCount} expired token(s) from file store`); + this.saveToFile(); + } + + return deletedCount; + } }