mirror of
https://github.com/wkulhanek/bonob.git
synced 2025-12-21 17:33:29 +01:00
Make Smapi responsible for turning app token into encrypted jwt (#71)
This commit is contained in:
@@ -2,6 +2,7 @@ import path from "path";
|
||||
import fs from "fs";
|
||||
import server from "./server";
|
||||
import logger from "./logger";
|
||||
|
||||
import {
|
||||
appendMimeTypeToClientFor,
|
||||
axiosImageFetcher,
|
||||
@@ -9,13 +10,13 @@ import {
|
||||
DEFAULT,
|
||||
Subsonic,
|
||||
} from "./subsonic";
|
||||
import encryption from "./encryption";
|
||||
import { InMemoryAccessTokens, sha256 } from "./access_tokens";
|
||||
import { InMemoryLinkCodes } from "./link_codes";
|
||||
import readConfig from "./config";
|
||||
import sonos, { bonobService } from "./sonos";
|
||||
import { MusicService } from "./music_service";
|
||||
import { SystemClock } from "./clock";
|
||||
import { jwtTokenSigner } from "./encryption";
|
||||
|
||||
const config = readConfig();
|
||||
|
||||
@@ -40,7 +41,6 @@ const artistImageFetcher = config.subsonic.artistImageCache
|
||||
|
||||
const subsonic = new Subsonic(
|
||||
config.subsonic.url,
|
||||
encryption(config.secret),
|
||||
streamUserAgent,
|
||||
artistImageFetcher
|
||||
);
|
||||
@@ -88,6 +88,7 @@ const app = server(
|
||||
applyContextPath: true,
|
||||
logRequests: true,
|
||||
version,
|
||||
tokenSigner: jwtTokenSigner(config.secret)
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -1,33 +1,89 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes, createHash } from "crypto";
|
||||
import {
|
||||
createCipheriv,
|
||||
createDecipheriv,
|
||||
randomBytes,
|
||||
createHash,
|
||||
} from "crypto";
|
||||
import jwt from "jsonwebtoken";
|
||||
|
||||
const ALGORITHM = "aes-256-cbc"
|
||||
const ALGORITHM = "aes-256-cbc";
|
||||
const IV = randomBytes(16);
|
||||
|
||||
export type Signer = {
|
||||
sign: (value: string) => string;
|
||||
verify: (token: string) => string;
|
||||
};
|
||||
|
||||
export const pSigner = (signer: Signer) => ({
|
||||
sign: (value: string): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
return resolve(signer.sign(value));
|
||||
} catch(e) {
|
||||
reject(`Failed to sign value: ${e}`);
|
||||
}
|
||||
});
|
||||
},
|
||||
verify: (token: string): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
return resolve(signer.verify(token));
|
||||
}catch(e) {
|
||||
reject(`Failed to verify value: ${e}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const jwtTokenSigner = (secret: string) => ({
|
||||
sign: (value: string) => jwt.sign(value, secret),
|
||||
verify: (token: string) => {
|
||||
try {
|
||||
return jwt.verify(token, secret) as string;
|
||||
} catch (e) {
|
||||
throw `Failed to decode jwt, try re-authorising account`;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export type Hash = {
|
||||
iv: string,
|
||||
encryptedData: string
|
||||
}
|
||||
iv: string;
|
||||
encryptedData: string;
|
||||
};
|
||||
|
||||
export type Encryption = {
|
||||
encrypt: (value:string) => Hash
|
||||
decrypt: (hash: Hash) => string
|
||||
}
|
||||
encrypt: (value: string) => Hash;
|
||||
decrypt: (hash: Hash) => string;
|
||||
};
|
||||
|
||||
const encryption = (secret: string): Encryption => {
|
||||
const key = createHash('sha256').update(String(secret)).digest('base64').substr(0, 32);
|
||||
const key = createHash("sha256")
|
||||
.update(String(secret))
|
||||
.digest("base64")
|
||||
.substr(0, 32);
|
||||
return {
|
||||
encrypt: (value: string) => {
|
||||
const cipher = createCipheriv(ALGORITHM, key, IV);
|
||||
return {
|
||||
iv: IV.toString("hex"),
|
||||
encryptedData: Buffer.concat([cipher.update(value), cipher.final()]).toString("hex")
|
||||
return {
|
||||
iv: IV.toString("hex"),
|
||||
encryptedData: Buffer.concat([
|
||||
cipher.update(value),
|
||||
cipher.final(),
|
||||
]).toString("hex"),
|
||||
};
|
||||
},
|
||||
decrypt: (hash: Hash) => {
|
||||
const decipher = createDecipheriv(ALGORITHM, key, Buffer.from(hash.iv, 'hex'));
|
||||
return Buffer.concat([decipher.update(Buffer.from(hash.encryptedData, 'hex')), decipher.final()]).toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
const decipher = createDecipheriv(
|
||||
ALGORITHM,
|
||||
key,
|
||||
Buffer.from(hash.iv, "hex")
|
||||
);
|
||||
return Buffer.concat([
|
||||
decipher.update(Buffer.from(hash.encryptedData, "hex")),
|
||||
decipher.final(),
|
||||
]).toString();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default encryption;
|
||||
|
||||
@@ -34,6 +34,7 @@ import { Icon, ICONS, festivals, features } from "./icon";
|
||||
import _, { shuffle } from "underscore";
|
||||
import morgan from "morgan";
|
||||
import { takeWithRepeats } from "./utils";
|
||||
import { jwtTokenSigner, Signer } from "./encryption";
|
||||
|
||||
export const BONOB_ACCESS_TOKEN_HEADER = "bat";
|
||||
|
||||
@@ -85,6 +86,7 @@ export type ServerOpts = {
|
||||
applyContextPath: boolean;
|
||||
logRequests: boolean;
|
||||
version: string;
|
||||
tokenSigner: Signer;
|
||||
};
|
||||
|
||||
const DEFAULT_SERVER_OPTS: ServerOpts = {
|
||||
@@ -95,6 +97,7 @@ const DEFAULT_SERVER_OPTS: ServerOpts = {
|
||||
applyContextPath: true,
|
||||
logRequests: false,
|
||||
version: "v?",
|
||||
tokenSigner: jwtTokenSigner(`bonob-${uuid()}`),
|
||||
};
|
||||
|
||||
function server(
|
||||
@@ -585,7 +588,8 @@ function server(
|
||||
musicService,
|
||||
accessTokens,
|
||||
clock,
|
||||
i8n
|
||||
i8n,
|
||||
serverOpts.tokenSigner
|
||||
);
|
||||
|
||||
if (serverOpts.applyContextPath) {
|
||||
|
||||
93
src/smapi.ts
93
src/smapi.ts
@@ -24,6 +24,7 @@ import { URLBuilder } from "./url_builder";
|
||||
import { asLANGs, I8N } from "./i8n";
|
||||
import { ICON, iconForGenre } from "./icon";
|
||||
import { uniq } from "underscore";
|
||||
import { pSigner, Signer } from "./encryption";
|
||||
|
||||
export const LOGIN_ROUTE = "/login";
|
||||
export const CREATE_REGISTRATION_ROUTE = "/registration/add";
|
||||
@@ -145,10 +146,12 @@ export function searchResult(
|
||||
class SonosSoap {
|
||||
linkCodes: LinkCodes;
|
||||
bonobUrl: URLBuilder;
|
||||
tokenSigner: Signer
|
||||
|
||||
constructor(bonobUrl: URLBuilder, linkCodes: LinkCodes) {
|
||||
constructor(bonobUrl: URLBuilder, linkCodes: LinkCodes, tokenSigner: Signer) {
|
||||
this.bonobUrl = bonobUrl;
|
||||
this.linkCodes = linkCodes;
|
||||
this.tokenSigner = tokenSigner
|
||||
}
|
||||
|
||||
getAppLink(): GetAppLinkResult {
|
||||
@@ -179,7 +182,7 @@ class SonosSoap {
|
||||
if (association) {
|
||||
return {
|
||||
getDeviceAuthTokenResult: {
|
||||
authToken: association.authToken,
|
||||
authToken: this.tokenSigner.sign(association.authToken),
|
||||
privateKey: "",
|
||||
userInfo: {
|
||||
nickname: association.nickname,
|
||||
@@ -321,39 +324,6 @@ export const artist = (bonobUrl: URLBuilder, artist: ArtistSummary) => ({
|
||||
albumArtURI: defaultArtistArtURI(bonobUrl, artist).href(),
|
||||
});
|
||||
|
||||
const auth = async (
|
||||
musicService: MusicService,
|
||||
accessTokens: AccessTokens,
|
||||
credentials?: Credentials
|
||||
) => {
|
||||
if (!credentials) {
|
||||
throw {
|
||||
Fault: {
|
||||
faultcode: "Client.LoginUnsupported",
|
||||
faultstring: "Missing credentials...",
|
||||
},
|
||||
};
|
||||
}
|
||||
const authToken = credentials.loginToken.token;
|
||||
const accessToken = accessTokens.mint(authToken);
|
||||
|
||||
return musicService
|
||||
.login(authToken)
|
||||
.then((musicLibrary) => ({
|
||||
musicLibrary,
|
||||
authToken,
|
||||
accessToken,
|
||||
}))
|
||||
.catch((_) => {
|
||||
throw {
|
||||
Fault: {
|
||||
faultcode: "Client.LoginUnauthorized",
|
||||
faultstring: "Credentials not found...",
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
function splitId<T>(id: string) {
|
||||
const [type, typeId] = id.split(":");
|
||||
return (t: T) => ({
|
||||
@@ -375,9 +345,10 @@ function bindSmapiSoapServiceToExpress(
|
||||
musicService: MusicService,
|
||||
accessTokens: AccessTokens,
|
||||
clock: Clock,
|
||||
i8n: I8N
|
||||
i8n: I8N,
|
||||
tokenSigner: Signer,
|
||||
) {
|
||||
const sonosSoap = new SonosSoap(bonobUrl, linkCodes);
|
||||
const sonosSoap = new SonosSoap(bonobUrl, linkCodes, tokenSigner);
|
||||
|
||||
const urlWithToken = (accessToken: string) =>
|
||||
bonobUrl.append({
|
||||
@@ -386,6 +357,32 @@ function bindSmapiSoapServiceToExpress(
|
||||
},
|
||||
});
|
||||
|
||||
const auth = async (
|
||||
credentials?: Credentials
|
||||
) => {
|
||||
if (!credentials) {
|
||||
throw {
|
||||
Fault: {
|
||||
faultcode: "Client.LoginUnsupported",
|
||||
faultstring: "Missing credentials...",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return pSigner(tokenSigner)
|
||||
.verify(credentials.loginToken.token)
|
||||
.then(authToken => ({ authToken, accessToken: accessTokens.mint(authToken) }))
|
||||
.then((tokens) => musicService.login(tokens.authToken).then(musicLibrary => ({ ...tokens, musicLibrary })))
|
||||
.catch((_) => {
|
||||
throw {
|
||||
Fault: {
|
||||
faultcode: "Client.LoginUnauthorized",
|
||||
faultstring: "Failed to authenticate, try Reauthorising your account in the sonos app",
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const soapyService = listen(
|
||||
app,
|
||||
soapPath,
|
||||
@@ -408,7 +405,7 @@ function bindSmapiSoapServiceToExpress(
|
||||
_,
|
||||
soapyHeaders: SoapyHeaders
|
||||
) =>
|
||||
auth(musicService, accessTokens, soapyHeaders?.credentials)
|
||||
auth(soapyHeaders?.credentials)
|
||||
.then(splitId(id))
|
||||
.then(({ accessToken, type, typeId }) => ({
|
||||
getMediaURIResult: bonobUrl
|
||||
@@ -423,7 +420,7 @@ function bindSmapiSoapServiceToExpress(
|
||||
_,
|
||||
soapyHeaders: SoapyHeaders
|
||||
) =>
|
||||
auth(musicService, accessTokens, soapyHeaders?.credentials)
|
||||
auth(soapyHeaders?.credentials)
|
||||
.then(splitId(id))
|
||||
.then(async ({ musicLibrary, accessToken, typeId }) =>
|
||||
musicLibrary.track(typeId!).then((it) => ({
|
||||
@@ -435,7 +432,7 @@ function bindSmapiSoapServiceToExpress(
|
||||
_,
|
||||
soapyHeaders: SoapyHeaders
|
||||
) =>
|
||||
auth(musicService, accessTokens, soapyHeaders?.credentials)
|
||||
auth(soapyHeaders?.credentials)
|
||||
.then(splitId(id))
|
||||
.then(async ({ musicLibrary, accessToken }) => {
|
||||
switch (id) {
|
||||
@@ -480,7 +477,7 @@ function bindSmapiSoapServiceToExpress(
|
||||
_,
|
||||
soapyHeaders: SoapyHeaders
|
||||
) =>
|
||||
auth(musicService, accessTokens, soapyHeaders?.credentials)
|
||||
auth(soapyHeaders?.credentials)
|
||||
.then(splitId(id))
|
||||
.then(async ({ musicLibrary, accessToken, type, typeId }) => {
|
||||
const paging = { _index: index, _count: count };
|
||||
@@ -552,7 +549,7 @@ function bindSmapiSoapServiceToExpress(
|
||||
soapyHeaders: SoapyHeaders,
|
||||
{ headers }: Pick<Request, "headers">
|
||||
) =>
|
||||
auth(musicService, accessTokens, soapyHeaders?.credentials)
|
||||
auth(soapyHeaders?.credentials)
|
||||
.then(splitId(id))
|
||||
.then(({ musicLibrary, accessToken, type, typeId }) => {
|
||||
const paging = { _index: index, _count: count };
|
||||
@@ -825,7 +822,7 @@ function bindSmapiSoapServiceToExpress(
|
||||
_,
|
||||
soapyHeaders: SoapyHeaders
|
||||
) =>
|
||||
auth(musicService, accessTokens, soapyHeaders?.credentials)
|
||||
auth(soapyHeaders?.credentials)
|
||||
.then(({ musicLibrary }) =>
|
||||
musicLibrary
|
||||
.createPlaylist(title)
|
||||
@@ -851,7 +848,7 @@ function bindSmapiSoapServiceToExpress(
|
||||
_,
|
||||
soapyHeaders: SoapyHeaders
|
||||
) =>
|
||||
auth(musicService, accessTokens, soapyHeaders?.credentials)
|
||||
auth(soapyHeaders?.credentials)
|
||||
.then(({ musicLibrary }) => musicLibrary.deletePlaylist(id))
|
||||
.then((_) => ({ deleteContainerResult: {} })),
|
||||
addToContainer: async (
|
||||
@@ -859,7 +856,7 @@ function bindSmapiSoapServiceToExpress(
|
||||
_,
|
||||
soapyHeaders: SoapyHeaders
|
||||
) =>
|
||||
auth(musicService, accessTokens, soapyHeaders?.credentials)
|
||||
auth(soapyHeaders?.credentials)
|
||||
.then(splitId(id))
|
||||
.then(({ musicLibrary, typeId }) =>
|
||||
musicLibrary.addToPlaylist(parentId.split(":")[1]!, typeId)
|
||||
@@ -870,7 +867,7 @@ function bindSmapiSoapServiceToExpress(
|
||||
_,
|
||||
soapyHeaders: SoapyHeaders
|
||||
) =>
|
||||
auth(musicService, accessTokens, soapyHeaders?.credentials)
|
||||
auth(soapyHeaders?.credentials)
|
||||
.then(splitId(id))
|
||||
.then((it) => ({
|
||||
...it,
|
||||
@@ -893,7 +890,7 @@ function bindSmapiSoapServiceToExpress(
|
||||
_,
|
||||
soapyHeaders: SoapyHeaders
|
||||
) =>
|
||||
auth(musicService, accessTokens, soapyHeaders?.credentials)
|
||||
auth(soapyHeaders?.credentials)
|
||||
.then(splitId(id))
|
||||
.then(({ musicLibrary, typeId }) =>
|
||||
musicLibrary.rate(typeId, ratingFromInt(Math.abs(rating)))
|
||||
@@ -905,7 +902,7 @@ function bindSmapiSoapServiceToExpress(
|
||||
_,
|
||||
soapyHeaders: SoapyHeaders
|
||||
) =>
|
||||
auth(musicService, accessTokens, soapyHeaders?.credentials)
|
||||
auth(soapyHeaders?.credentials)
|
||||
.then(splitId(id))
|
||||
.then(({ musicLibrary, type, typeId }) => {
|
||||
switch (type) {
|
||||
|
||||
@@ -28,7 +28,6 @@ import fse from "fs-extra";
|
||||
import path from "path";
|
||||
|
||||
import axios, { AxiosRequestConfig } from "axios";
|
||||
import { Encryption } from "./encryption";
|
||||
import randomString from "./random_string";
|
||||
import { b64Encode, b64Decode } from "./b64";
|
||||
import logger from "./logger";
|
||||
@@ -369,18 +368,15 @@ const AlbumQueryTypeToSubsonicType: Record<AlbumQueryType, string> = {
|
||||
|
||||
export class Subsonic implements MusicService {
|
||||
url: string;
|
||||
encryption: Encryption;
|
||||
streamClientApplication: StreamClientApplication;
|
||||
externalImageFetcher: ImageFetcher;
|
||||
|
||||
constructor(
|
||||
url: string,
|
||||
encryption: Encryption,
|
||||
streamClientApplication: StreamClientApplication = DEFAULT,
|
||||
externalImageFetcher: ImageFetcher = axiosImageFetcher
|
||||
) {
|
||||
this.url = url;
|
||||
this.encryption = encryption;
|
||||
this.streamClientApplication = streamClientApplication;
|
||||
this.externalImageFetcher = externalImageFetcher;
|
||||
}
|
||||
@@ -428,15 +424,14 @@ export class Subsonic implements MusicService {
|
||||
this.getJSON(credentials, "/rest/ping.view")
|
||||
.then(() => ({
|
||||
authToken: b64Encode(
|
||||
JSON.stringify(this.encryption.encrypt(JSON.stringify(credentials)))
|
||||
JSON.stringify(credentials)
|
||||
),
|
||||
userId: credentials.username,
|
||||
nickname: credentials.username,
|
||||
}))
|
||||
.catch((e) => ({ message: `${e}` }));
|
||||
|
||||
parseToken = (token: string): Credentials =>
|
||||
JSON.parse(this.encryption.decrypt(JSON.parse(b64Decode(token))));
|
||||
parseToken = (token: string): Credentials => JSON.parse(b64Decode(token));
|
||||
|
||||
getArtists = (
|
||||
credentials: Credentials
|
||||
|
||||
Reference in New Issue
Block a user