Compare commits

...

5 Commits

Author SHA1 Message Date
8e207fd483 Udpates to docs 2025-10-26 12:01:07 +01:00
Wolfgang Kulhanek
2403d6cdc6 Another token expiration fix. 2025-10-24 15:07:09 +02:00
Wolfgang Kulhanek
03434fb362 More token refresh fixes 2025-10-24 14:56:56 +02:00
Wolfgang Kulhanek
a47581c3fe Fix tests 2025-10-24 14:47:53 +02:00
Wolfgang Kulhanek
48a71031c6 Update token management again 2025-10-24 13:42:55 +02:00
5 changed files with 21 additions and 27 deletions

View File

@@ -4,7 +4,7 @@ Run Bonob on your server.
== Updates made to original code == Updates made to original code
* Proper Token handling after login. Also handling of periodic token refresh. * Proper Token handling after login. Also handling of periodic token refresh. Something is still funky here after a day or two...
* Store Tokens in an SQLite database (in mounted `/config` directory). * Store Tokens in an SQLite database (in mounted `/config` directory).
* Added variable `BNB_TOKEN_CLEANUP_INTERVAL` with a default of `60` (minutes) to set how often expired tokens should be cleaned up out of the database. * Added variable `BNB_TOKEN_CLEANUP_INTERVAL` with a default of `60` (minutes) to set how often expired tokens should be cleaned up out of the database.
* Multi-account logins. Register one Bonob and log in with multiple Navidrome users for easy account switching in the Sonos app. * Multi-account logins. Register one Bonob and log in with multiple Navidrome users for easy account switching in the Sonos app.
@@ -27,6 +27,7 @@ Run Bonob on your server.
Bonob now needs a volume to store the token database. In the example below that directory is `/var/containers/bonob`. Adapt as needed. Bonob now needs a volume to store the token database. In the example below that directory is `/var/containers/bonob`. Adapt as needed.
Also the example below uses a `bonob` user on the system with ID `1210` and group `100`. The database directory should be owned by that user. Also the example below uses a `bonob` user on the system with ID `1210` and group `100`. The database directory should be owned by that user.
Also for `BNB_SUBSONIC_URL` you can use the internal or external URL. So instead of `https://music.mydomain.com` you could use `http://192.168.1.100:4533` if your Navidrome runs on a server with IP `192.168.1.100`.
.Example systemd file (`/usr/lib/systemd/system/bonob.service`) .Example systemd file (`/usr/lib/systemd/system/bonob.service`)
[source] [source]
---- ----

View File

@@ -5,7 +5,7 @@
+ +
image::images/about.png[] image::images/about.png[]
* Navidrome running and available from the Internet. E.g. via https://music.mydomain.com * Navidrome running and available from the server that Bonob is running on. This can be a public URL like https://music.mydomain.com or just a local URL like http://192.168.1.100:4533.
* Bonob running and available from the Internet. E.g. via https://bonob.mydomain.com * Bonob running and available from the Internet. E.g. via https://bonob.mydomain.com
You can use any method to make these URLs available. Cloudflare Tunnels, Pangolin, reverse proxy, etc. You can use any method to make these URLs available. Cloudflare Tunnels, Pangolin, reverse proxy, etc.
@@ -21,7 +21,7 @@ You can use any method to make these URLs available. Cloudflare Tunnels, Pangoli
*** Service Name: Navidrome *** Service Name: Navidrome
*** Service Availability: Global *** Service Availability: Global
*** Checkbox checked *** Checkbox checked
*** Website/Social Media URLs: https://music.mydomain.com (Some URL - e.g. your Navidrome server) *** Website/Social Media URLs: https://music.mydomain.com (Some URL - e.g. your Navidrome server). This has to be a valid URL.
** Sonos Music API ** Sonos Music API
*** Integration ID: com.mydomain.music (your domain in reverse) *** Integration ID: com.mydomain.music (your domain in reverse)

View File

@@ -250,11 +250,10 @@ class SonosSoap {
logger.debug("Current tokens: " + JSON.stringify(this.tokenStore.getAll())); logger.debug("Current tokens: " + JSON.stringify(this.tokenStore.getAll()));
return this.tokenStore.get(token); return this.tokenStore.get(token);
} }
associateCredentialsForToken(token: string, fullSmapiToken: SmapiToken, oldToken?:string) { associateCredentialsForToken(token: string, fullSmapiToken: SmapiToken) {
logger.debug("Adding token: " + token + " " + JSON.stringify(fullSmapiToken)); logger.debug("Adding token: " + token + " " + JSON.stringify(fullSmapiToken));
if(oldToken) { // Don't immediately delete old token to avoid race conditions
this.tokenStore.delete(oldToken); // The cleanup process will handle expired tokens later
}
this.tokenStore.set(token, fullSmapiToken); this.tokenStore.set(token, fullSmapiToken);
} }
} }
@@ -488,11 +487,9 @@ function bindSmapiSoapServiceToExpress(
const swapToken = (expiredToken: string | undefined) => (newToken: SmapiToken) => { const swapToken = (expiredToken: string | undefined) => (newToken: SmapiToken) => {
logger.debug("oldToken: " + expiredToken); logger.debug("oldToken: " + expiredToken);
logger.debug("newToken: " + JSON.stringify(newToken)); logger.debug("newToken: " + JSON.stringify(newToken));
if (expiredToken) { // Always add the new token, but don't immediately delete the old one
sonosSoap.associateCredentialsForToken(newToken.token, newToken, expiredToken); // to avoid race conditions where Sonos might still be using the old token
} else { sonosSoap.associateCredentialsForToken(newToken.token, newToken);
sonosSoap.associateCredentialsForToken(newToken.token, newToken);
}
return TE.right(newToken); return TE.right(newToken);
} }
@@ -540,8 +537,6 @@ function bindSmapiSoapServiceToExpress(
throw SMAPI_FAULT_LOGIN_UNAUTHORIZED; throw SMAPI_FAULT_LOGIN_UNAUTHORIZED;
}); });
} else if (isExpiredTokenError(authOrFail)) { } 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
logger.info("Token expired, attempting refresh..."); logger.info("Token expired, attempting refresh...");
throw await pipe( throw await pipe(
musicService.refreshToken(authOrFail.expiredToken), musicService.refreshToken(authOrFail.expiredToken),
@@ -549,7 +544,7 @@ function bindSmapiSoapServiceToExpress(
logger.info("Token refresh successful, issuing new SMAPI token"); logger.info("Token refresh successful, issuing new SMAPI token");
return smapiAuthTokens.issue(it.serviceToken); return smapiAuthTokens.issue(it.serviceToken);
}), }),
TE.tap(swapToken(undefined)), TE.tap(swapToken(authOrFail.expiredToken)), // Pass the expired token to ensure it gets deleted
TE.map((newToken) => ({ TE.map((newToken) => ({
Fault: { Fault: {
faultcode: "Client.TokenRefreshRequired", faultcode: "Client.TokenRefreshRequired",
@@ -615,12 +610,10 @@ function bindSmapiSoapServiceToExpress(
throw fault.toSmapiFault(); 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( return pipe(
musicService.refreshToken(serviceToken), musicService.refreshToken(serviceToken),
TE.map((it) => smapiAuthTokens.issue(it.serviceToken)), TE.map((it) => smapiAuthTokens.issue(it.serviceToken)),
TE.tap(swapToken(undefined)), // ignores the return value, like a tee or peek TE.tap(swapToken(serviceToken)), // Pass the expired token to ensure it gets deleted
TE.map((it) => ({ TE.map((it) => ({
refreshAuthTokenResult: { refreshAuthTokenResult: {
authToken: it.token, authToken: it.token,

View File

@@ -45,9 +45,9 @@ export class InMemorySmapiTokenStore implements SmapiTokenStore {
// Do NOT delete ExpiredTokenError as those can still be refreshed // Do NOT delete ExpiredTokenError as those can still be refreshed
if (E.isLeft(verifyResult)) { if (E.isLeft(verifyResult)) {
const error = verifyResult.left; const error = verifyResult.left;
// Only delete invalid tokens, not expired ones (which can be refreshed) // Delete both invalid and expired tokens to prevent accumulation
if (error._tag === 'InvalidTokenError') { if (error._tag === 'InvalidTokenError' || error._tag === 'ExpiredTokenError') {
logger.debug(`Deleting invalid token from in-memory store`); logger.debug(`Deleting ${error._tag} token from in-memory store`);
delete this.tokens[tokenKey]; delete this.tokens[tokenKey];
deletedCount++; deletedCount++;
} }
@@ -146,9 +146,9 @@ export class FileSmapiTokenStore implements SmapiTokenStore {
// Do NOT delete ExpiredTokenError as those can still be refreshed // Do NOT delete ExpiredTokenError as those can still be refreshed
if (E.isLeft(verifyResult)) { if (E.isLeft(verifyResult)) {
const error = verifyResult.left; const error = verifyResult.left;
// Only delete invalid tokens, not expired ones (which can be refreshed) // Delete both invalid and expired tokens to prevent accumulation
if (error._tag === 'InvalidTokenError') { if (error._tag === 'InvalidTokenError' || error._tag === 'ExpiredTokenError') {
logger.debug(`Deleting invalid token from file store`); logger.debug(`Deleting ${error._tag} token from file store`);
delete this.tokens[tokenKey]; delete this.tokens[tokenKey];
deletedCount++; deletedCount++;
} }

View File

@@ -126,9 +126,9 @@ export class SQLiteSmapiTokenStore implements SmapiTokenStore {
// Do NOT delete ExpiredTokenError as those can still be refreshed // Do NOT delete ExpiredTokenError as those can still be refreshed
if (E.isLeft(verifyResult)) { if (E.isLeft(verifyResult)) {
const error = verifyResult.left; const error = verifyResult.left;
// Only delete invalid tokens, not expired ones (which can be refreshed) // Delete both invalid and expired tokens to prevent accumulation
if (error._tag === 'InvalidTokenError') { if (error._tag === 'InvalidTokenError' || error._tag === 'ExpiredTokenError') {
logger.debug(`Deleting invalid token from SQLite store`); logger.debug(`Deleting ${error._tag} token from SQLite store`);
this.delete(tokenKey); this.delete(tokenKey);
deletedCount++; deletedCount++;
} }