SmapiAuthTokens that expire, with sonos refreshAuthToken functionality (#81)

Bearer token to Authorization header for stream requests
Versioned SMAPI Tokens
This commit is contained in:
Simon J
2021-12-02 11:03:52 +11:00
committed by GitHub
parent 89340dd454
commit d1300b8119
24 changed files with 1792 additions and 1330 deletions

View File

@@ -1,112 +0,0 @@
import { Dayjs } from "dayjs";
import { v4 as uuid } from "uuid";
import crypto from "crypto";
import { Encryption } from "./encryption";
import logger from "./logger";
import { Clock, SystemClock } from "./clock";
type AccessToken = {
value: string;
authToken: string;
expiry: Dayjs;
};
export interface AccessTokens {
mint(authToken: string): string;
authTokenFor(value: string): string | undefined;
}
export class ExpiringAccessTokens implements AccessTokens {
tokens = new Map<string, AccessToken>();
clock: Clock;
constructor(clock: Clock = SystemClock) {
this.clock = clock;
}
mint(authToken: string): string {
this.clearOutExpired();
const accessToken = {
value: uuid(),
authToken,
expiry: this.clock.now().add(12, "hours"),
};
this.tokens.set(accessToken.value, accessToken);
return accessToken.value;
}
authTokenFor(value: string): string | undefined {
this.clearOutExpired();
return this.tokens.get(value)?.authToken;
}
clearOutExpired() {
Array.from(this.tokens.values())
.filter((it) => it.expiry.isBefore(this.clock.now()))
.forEach((expired) => {
this.tokens.delete(expired.value);
});
}
count = () => this.tokens.size;
}
export class EncryptedAccessTokens implements AccessTokens {
encryption: Encryption;
constructor(encryption: Encryption) {
this.encryption = encryption;
}
mint = (authToken: string): string => this.encryption.encrypt(authToken);
authTokenFor(value: string): string | undefined {
try {
return this.encryption.decrypt(value);
} catch {
logger.warn("Failed to decrypt access token...");
return undefined;
}
}
}
export class AccessTokenPerAuthToken implements AccessTokens {
authTokenToAccessToken = new Map<string, string>();
accessTokenToAuthToken = new Map<string, string>();
mint = (authToken: string): string => {
if (this.authTokenToAccessToken.has(authToken)) {
return this.authTokenToAccessToken.get(authToken)!;
} else {
const accessToken = uuid();
this.authTokenToAccessToken.set(authToken, accessToken);
this.accessTokenToAuthToken.set(accessToken, authToken);
return accessToken;
}
};
authTokenFor = (value: string): string | undefined => this.accessTokenToAuthToken.get(value);
}
export const sha256 = (salt: string) => (authToken: string) => crypto
.createHash("sha256")
.update(`${authToken}${salt}`)
.digest("hex")
export class InMemoryAccessTokens implements AccessTokens {
tokens = new Map<string, string>();
minter;
constructor(minter: (authToken: string) => string) {
this.minter = minter
}
mint = (authToken: string): string => {
const accessToken = this.minter(authToken);
this.tokens.set(accessToken, authToken);
return accessToken;
}
authTokenFor = (value: string): string | undefined => this.tokens.get(value);
}

30
src/api_tokens.ts Normal file
View File

@@ -0,0 +1,30 @@
import crypto from "crypto";
export interface APITokens {
mint(authToken: string): string;
authTokenFor(apiToken: string): string | undefined;
}
export const sha256 = (salt: string) => (value: string) => crypto
.createHash("sha256")
.update(`${value}${salt}`)
.digest("hex")
export class InMemoryAPITokens implements APITokens {
tokens = new Map<string, string>();
minter;
constructor(minter: (authToken: string) => string = sha256('bonob')) {
this.minter = minter
}
mint = (authToken: string): string => {
const accessToken = this.minter(authToken);
this.tokens.set(accessToken, authToken);
return accessToken;
}
authTokenFor = (apiToken: string): string | undefined => this.tokens.get(apiToken);
}

View File

@@ -10,15 +10,16 @@ import {
DEFAULT,
Subsonic,
} from "./subsonic";
import { InMemoryAccessTokens, sha256 } from "./access_tokens";
import { InMemoryAPITokens, sha256 } from "./api_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 { jwtSigner } from "./encryption";
import { JWTSmapiLoginTokens } from "./smapi_auth";
const config = readConfig();
const clock = SystemClock;
logger.info(`Starting bonob with config ${JSON.stringify(config)}`);
@@ -47,8 +48,8 @@ const subsonic = new Subsonic(
const featureFlagAwareMusicService: MusicService = {
generateToken: subsonic.generateToken,
login: (authToken: string) =>
subsonic.login(authToken).then((library) => {
login: (serviceToken: string) =>
subsonic.login(serviceToken).then((library) => {
return {
...library,
scrobble: (id: string) => {
@@ -82,13 +83,13 @@ const app = server(
featureFlagAwareMusicService,
{
linkCodes: () => new InMemoryLinkCodes(),
accessTokens: () => new InMemoryAccessTokens(sha256(config.secret)),
clock: SystemClock,
apiTokens: () => new InMemoryAPITokens(sha256(config.secret)),
clock,
iconColors: config.icons,
applyContextPath: true,
logRequests: true,
version,
tokenSigner: jwtSigner(config.secret),
smapiAuthTokens: new JWTSmapiLoginTokens(clock, config.secret, '1h'),
externalImageResolver: artistImageFetcher
}
);

View File

@@ -14,3 +14,15 @@ export interface Clock {
}
export const SystemClock = { now: () => dayjs() };
export class FixedClock implements Clock {
time: Dayjs;
constructor(time: Dayjs = dayjs()) {
this.time = time;
}
add = (t: number, unit: dayjs.UnitTypeShort) => this.time = this.time.add(t, unit)
now = () => this.time;
}

View File

@@ -4,54 +4,12 @@ import {
randomBytes,
createHash,
} from "crypto";
import jwt from "jsonwebtoken";
import jws from "jws";
const ALGORITHM = "aes-256-cbc";
const IV = randomBytes(16);
function isError(thing: any): thing is Error {
return thing.name && thing.message
}
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) {
if(isError(e)) reject(e.message)
else reject(`Failed to sign value: ${e}`);
}
});
},
verify: (token: string): Promise<string> => {
return new Promise((resolve, reject) => {
try {
return resolve(signer.verify(token));
}catch(e) {
if(isError(e)) reject(e.message)
else reject(`Failed to verify value: ${e}`);
}
});
}
});
export const jwtSigner = (secret: string) => ({
sign: (value: string) => jwt.sign(value, secret),
verify: (token: string) => {
try {
return jwt.verify(token, secret) as string;
} catch (e) {
throw new Error(`Failed to verify jwt, try re-authorising account within sonos app`);
}
},
});
export type Hash = {
iv: string;

View File

@@ -2,7 +2,7 @@ import { v4 as uuid } from 'uuid';
export type Association = {
authToken: string
serviceToken: string
userId: string
nickname: string
}

View File

@@ -5,7 +5,7 @@ export type Credentials = { username: string; password: string };
export function isSuccess(
authResult: AuthSuccess | AuthFailure
): authResult is AuthSuccess {
return (authResult as AuthSuccess).authToken !== undefined;
return (authResult as AuthSuccess).serviceToken !== undefined;
}
export function isFailure(
@@ -15,7 +15,7 @@ export function isFailure(
}
export type AuthSuccess = {
authToken: string;
serviceToken: string;
userId: string;
nickname: string;
};
@@ -156,7 +156,7 @@ export const asArtistAlbumPairs = (artists: Artist[]): [Artist, Album][] =>
export interface MusicService {
generateToken(credentials: Credentials): Promise<AuthSuccess | AuthFailure>;
login(authToken: string): Promise<MusicLibrary>;
login(serviceToken: string): Promise<MusicLibrary>;
}
export interface MusicLibrary {

View File

@@ -1,4 +1,4 @@
import { option as O } from "fp-ts";
import { either as E } from "fp-ts";
import express, { Express, Request } from "express";
import * as Eta from "eta";
import path from "path";
@@ -24,7 +24,7 @@ import {
import { LinkCodes, InMemoryLinkCodes } from "./link_codes";
import { MusicService, isSuccess } from "./music_service";
import bindSmapiSoapServiceToExpress from "./smapi";
import { AccessTokens, AccessTokenPerAuthToken } from "./access_tokens";
import { APITokens, InMemoryAPITokens } from "./api_tokens";
import logger from "./logger";
import { Clock, SystemClock } from "./clock";
import { pipe } from "fp-ts/lib/function";
@@ -34,9 +34,9 @@ import { Icon, ICONS, festivals, features } from "./icon";
import _, { shuffle } from "underscore";
import morgan from "morgan";
import { takeWithRepeats } from "./utils";
import { jwtSigner, Signer } from "./encryption";
import { parse } from "./burn";
import { axiosImageFetcher, ImageFetcher } from "./subsonic";
import { JWTSmapiLoginTokens, SmapiAuthTokens, SmapiToken } from "./smapi_auth";
export const BONOB_ACCESS_TOKEN_HEADER = "bat";
@@ -79,7 +79,7 @@ export class RangeBytesFromFilter extends Transform {
export type ServerOpts = {
linkCodes: () => LinkCodes;
accessTokens: () => AccessTokens;
apiTokens: () => APITokens;
clock: Clock;
iconColors: {
foregroundColor: string | undefined;
@@ -88,20 +88,24 @@ export type ServerOpts = {
applyContextPath: boolean;
logRequests: boolean;
version: string;
tokenSigner: Signer;
smapiAuthTokens: SmapiAuthTokens;
externalImageResolver: ImageFetcher;
};
const DEFAULT_SERVER_OPTS: ServerOpts = {
linkCodes: () => new InMemoryLinkCodes(),
accessTokens: () => new AccessTokenPerAuthToken(),
apiTokens: () => new InMemoryAPITokens(),
clock: SystemClock,
iconColors: { foregroundColor: undefined, backgroundColor: undefined },
applyContextPath: true,
logRequests: false,
version: "v?",
tokenSigner: jwtSigner(`bonob-${uuid()}`),
externalImageResolver: axiosImageFetcher
smapiAuthTokens: new JWTSmapiLoginTokens(
SystemClock,
`bonob-${uuid()}`,
"1m"
),
externalImageResolver: axiosImageFetcher,
};
function server(
@@ -114,7 +118,8 @@ function server(
const serverOpts = { ...DEFAULT_SERVER_OPTS, ...opts };
const linkCodes = serverOpts.linkCodes();
const accessTokens = serverOpts.accessTokens();
const smapiAuthTokens = serverOpts.smapiAuthTokens;
const apiTokens = serverOpts.apiTokens();
const clock = serverOpts.clock;
const startUpTime = dayjs();
@@ -228,30 +233,33 @@ function server(
message: lang("invalidLinkCode"),
});
} else {
return musicService.generateToken({
username,
password,
}).then(authResult => {
if (isSuccess(authResult)) {
linkCodes.associate(linkCode, authResult);
return res.render("success", {
lang,
message: lang("loginSuccessful"),
});
} else {
return musicService
.generateToken({
username,
password,
})
.then((authResult) => {
if (isSuccess(authResult)) {
linkCodes.associate(linkCode, authResult);
return res.render("success", {
lang,
message: lang("loginSuccessful"),
});
} else {
return res.status(403).render("failure", {
lang,
message: lang("loginFailed"),
cause: authResult.message,
});
}
})
.catch((e) => {
return res.status(403).render("failure", {
lang,
message: lang("loginFailed"),
cause: authResult.message,
cause: `Unexpected error occured - ${e}`,
});
}
}).catch(e => {
return res.status(403).render("failure", {
lang,
message: lang("loginFailed"),
cause: `Unexpected error occured - ${e}`,
});
});
}
});
@@ -276,23 +284,36 @@ function server(
const nowPlayingRatingsMatch = (value: number) => {
const rating = ratingFromInt(value);
const nextLove = { ...rating, love: !rating.love };
const nextStar = { ...rating, stars: (rating.stars === 5 ? 0 : rating.stars + 1) }
const nextStar = {
...rating,
stars: rating.stars === 5 ? 0 : rating.stars + 1,
};
const loveRatingIcon = bonobUrl.append({pathname: rating.love ? '/love-selected.svg' : '/love-unselected.svg'}).href();
const starsRatingIcon = bonobUrl.append({pathname: `/star${rating.stars}.svg`}).href();
const loveRatingIcon = bonobUrl
.append({
pathname: rating.love ? "/love-selected.svg" : "/love-unselected.svg",
})
.href();
const starsRatingIcon = bonobUrl
.append({ pathname: `/star${rating.stars}.svg` })
.href();
return `<Match propname="rating" value="${value}">
<Ratings>
<Rating Id="${ratingAsInt(nextLove)}" AutoSkip="NEVER" OnSuccessStringId="LOVE_SUCCESS" StringId="LOVE">
<Rating Id="${ratingAsInt(
nextLove
)}" AutoSkip="NEVER" OnSuccessStringId="LOVE_SUCCESS" StringId="LOVE">
<Icon Controller="universal" LastModified="${LastModified}" Uri="${loveRatingIcon}" />
</Rating>
<Rating Id="${-ratingAsInt(nextStar)}" AutoSkip="NEVER" OnSuccessStringId="STAR_SUCCESS" StringId="STAR">
<Rating Id="${-ratingAsInt(
nextStar
)}" AutoSkip="NEVER" OnSuccessStringId="STAR_SUCCESS" StringId="STAR">
<Icon Controller="universal" LastModified="${LastModified}" Uri="${starsRatingIcon}" />
</Rating>
</Ratings>
</Match>`
}
</Match>`;
};
res.type("application/xml").send(`<?xml version="1.0" encoding="utf-8" ?>
<Presentation>
<BrowseOptions PageSize="30" />
@@ -348,21 +369,32 @@ function server(
const trace = uuid();
logger.info(
`${trace} bnb<- ${req.method} ${req.path}?${
JSON.stringify(req.query)
}, headers=${JSON.stringify(req.headers)}`
`${trace} bnb<- ${req.method} ${req.path}?${JSON.stringify(
req.query
)}, headers=${JSON.stringify({ ...req.headers, "authorization": "***" })}`
);
const authToken = pipe(
req.query[BONOB_ACCESS_TOKEN_HEADER] as string,
O.fromNullable,
O.map((accessToken) => accessTokens.authTokenFor(accessToken)),
O.getOrElseW(() => undefined)
const authHeader = E.fromNullable("Missing header");
const bearerToken = E.fromNullable("No Bearer token");
const serviceToken = pipe(
authHeader(req.headers["authorization"] as string),
E.chain(authorization => pipe(
authorization.match(/Bearer (?<token>.*)/),
bearerToken,
E.map(match => match[1]!)
)),
E.chain(bearerToken => pipe(
smapiAuthTokens.verify(bearerToken as unknown as SmapiToken),
E.mapLeft(_ => "Bearer token failed to verify")
)),
E.getOrElseW(() => undefined)
);
if (!authToken) {
if (!serviceToken) {
return res.status(401).send();
} else {
return musicService
.login(authToken)
.login(serviceToken)
.then((it) =>
it
.stream({
@@ -382,7 +414,7 @@ function server(
contentType
.split(";")
.map((it) => it.trim())
.map((it) => sonosifyMimeType(it))
.map(sonosifyMimeType)
.join("; ");
const respondWith = ({
@@ -532,27 +564,31 @@ function server(
];
app.get("/art/:burns/size/:size", (req, res) => {
const authToken = accessTokens.authTokenFor(
const serviceToken = apiTokens.authTokenFor(
req.query[BONOB_ACCESS_TOKEN_HEADER] as string
);
const urns = req.params["burns"]!.split("&").map(parse);
const size = Number.parseInt(req.params["size"]!);
if (!authToken) {
if (!serviceToken) {
return res.status(401).send();
} else if (!(size > 0)) {
return res.status(400).send();
}
return musicService
.login(authToken)
.then((musicLibrary) => Promise.all(urns.map((it) => {
if(it.system == "external") {
return serverOpts.externalImageResolver(it.resource);
} else {
return musicLibrary.coverArt(it, size);
}
})))
.login(serviceToken)
.then((musicLibrary) =>
Promise.all(
urns.map((it) => {
if (it.system == "external") {
return serverOpts.externalImageResolver(it.resource);
} else {
return musicLibrary.coverArt(it, size);
}
})
)
)
.then((coverArts) => coverArts.filter((it) => it))
.then(shuffle)
.then((coverArts) => {
@@ -603,10 +639,10 @@ function server(
bonobUrl,
linkCodes,
musicService,
accessTokens,
apiTokens,
clock,
i8n,
serverOpts.tokenSigner
serverOpts.smapiAuthTokens
);
if (serverOpts.applyContextPath) {

View File

@@ -3,8 +3,8 @@ import { Express, Request } from "express";
import { listen } from "soap";
import { readFileSync } from "fs";
import path from "path";
import { option as O, either as E } from "fp-ts";
import { pipe } from "fp-ts/lib/function";
import { option as O } from "fp-ts";
import logger from "./logger";
@@ -21,14 +21,20 @@ import {
slice2,
Track,
} from "./music_service";
import { AccessTokens } from "./access_tokens";
import { APITokens } from "./api_tokens";
import { Clock } from "./clock";
import { URLBuilder } from "./url_builder";
import { asLANGs, I8N } from "./i8n";
import { ICON, iconForGenre } from "./icon";
import _, { uniq } from "underscore";
import { pSigner, Signer } from "./encryption";
import { BUrn, formatForURL } from "./burn";
import {
InvalidTokenError,
isSmapiRefreshTokenResultFault,
MissingLoginTokenError,
SmapiAuthTokens,
smapiTokenAsString,
} from "./smapi_auth";
export const LOGIN_ROUTE = "/login";
export const CREATE_REGISTRATION_ROUTE = "/registration/add";
@@ -60,6 +66,7 @@ const WSDL_FILE = path.resolve(
export type Credentials = {
loginToken: {
token: string;
key: string;
householdId: string;
};
deviceId: string;
@@ -150,12 +157,19 @@ export function searchResult(
class SonosSoap {
linkCodes: LinkCodes;
bonobUrl: URLBuilder;
tokenSigner: Signer
smapiAuthTokens: SmapiAuthTokens;
clock: Clock;
constructor(bonobUrl: URLBuilder, linkCodes: LinkCodes, tokenSigner: Signer) {
constructor(
bonobUrl: URLBuilder,
linkCodes: LinkCodes,
smapiAuthTokens: SmapiAuthTokens,
clock: Clock
) {
this.bonobUrl = bonobUrl;
this.linkCodes = linkCodes;
this.tokenSigner = tokenSigner
this.smapiAuthTokens = smapiAuthTokens;
this.clock = clock;
}
getAppLink(): GetAppLinkResult {
@@ -184,10 +198,13 @@ class SonosSoap {
}): GetDeviceAuthTokenResult {
const association = this.linkCodes.associationFor(linkCode);
if (association) {
const smapiAuthToken = this.smapiAuthTokens.issue(
association.serviceToken
);
return {
getDeviceAuthTokenResult: {
authToken: this.tokenSigner.sign(association.authToken),
privateKey: "",
authToken: smapiAuthToken.token,
privateKey: smapiAuthToken.key,
userInfo: {
nickname: association.nickname,
userIdHashCode: crypto
@@ -249,13 +266,18 @@ export const playlistAlbumArtURL = (
bonobUrl: URLBuilder,
playlist: Playlist
) => {
const burns: BUrn[] = uniq(playlist.entries.filter(it => it.coverArt != undefined), it => it.album.id).map((it) => it.coverArt!);
console.log(`### playlist ${playlist.name} burns -> ${JSON.stringify(burns)}`)
const burns: BUrn[] = uniq(
playlist.entries.filter((it) => it.coverArt != undefined),
(it) => it.album.id
).map((it) => it.coverArt!);
if (burns.length == 0) {
return iconArtURI(bonobUrl, "error");
} else {
return bonobUrl.append({
pathname: `/art/${burns.slice(0, 9).map(it => encodeURIComponent(formatForURL(it))).join("&")}/size/180`,
pathname: `/art/${burns
.slice(0, 9)
.map((it) => encodeURIComponent(formatForURL(it)))
.join("&")}/size/180`,
});
}
};
@@ -263,12 +285,17 @@ export const playlistAlbumArtURL = (
export const defaultAlbumArtURI = (
bonobUrl: URLBuilder,
{ coverArt }: { coverArt: BUrn | undefined }
) => pipe(
coverArt,
O.fromNullable,
O.map(it => bonobUrl.append({ pathname: `/art/${encodeURIComponent(formatForURL(it))}/size/180` })),
O.getOrElseW(() => iconArtURI(bonobUrl, "vinyl"))
);
) =>
pipe(
coverArt,
O.fromNullable,
O.map((it) =>
bonobUrl.append({
pathname: `/art/${encodeURIComponent(formatForURL(it))}/size/180`,
})
),
O.getOrElseW(() => iconArtURI(bonobUrl, "vinyl"))
);
export const iconArtURI = (bonobUrl: URLBuilder, icon: ICON) =>
bonobUrl.append({
@@ -278,12 +305,17 @@ export const iconArtURI = (bonobUrl: URLBuilder, icon: ICON) =>
export const defaultArtistArtURI = (
bonobUrl: URLBuilder,
artist: ArtistSummary
) => pipe(
artist.image,
O.fromNullable,
O.map(it => bonobUrl.append({ pathname: `/art/${encodeURIComponent(formatForURL(it))}/size/180` })),
O.getOrElseW(() => iconArtURI(bonobUrl, "vinyl"))
);
) =>
pipe(
artist.image,
O.fromNullable,
O.map((it) =>
bonobUrl.append({
pathname: `/art/${encodeURIComponent(formatForURL(it))}/size/180`,
})
),
O.getOrElseW(() => iconArtURI(bonobUrl, "vinyl"))
);
export const sonosifyMimeType = (mimeType: string) =>
mimeType == "audio/x-flac" ? "audio/flac" : mimeType;
@@ -312,7 +344,7 @@ export const track = (bonobUrl: URLBuilder, track: Track) => ({
album: track.album.name,
albumId: `album:${track.album.id}`,
albumArtist: track.artist.name,
albumArtistId: track.artist.id? `artist:${track.artist.id}` : undefined,
albumArtistId: track.artist.id ? `artist:${track.artist.id}` : undefined,
albumArtURI: defaultAlbumArtURI(bonobUrl, track).href(),
artist: track.artist.name,
artistId: track.artist.id ? `artist:${track.artist.id}` : undefined,
@@ -353,12 +385,12 @@ function bindSmapiSoapServiceToExpress(
bonobUrl: URLBuilder,
linkCodes: LinkCodes,
musicService: MusicService,
accessTokens: AccessTokens,
apiKeys: APITokens,
clock: Clock,
i8n: I8N,
tokenSigner: Signer,
smapiAuthTokens: SmapiAuthTokens
) {
const sonosSoap = new SonosSoap(bonobUrl, linkCodes, tokenSigner);
const sonosSoap = new SonosSoap(bonobUrl, linkCodes, smapiAuthTokens, clock);
const urlWithToken = (accessToken: string) =>
bonobUrl.append({
@@ -367,31 +399,47 @@ function bindSmapiSoapServiceToExpress(
},
});
const auth = async (
credentials?: Credentials
) => {
if (!credentials) {
throw {
Fault: {
faultcode: "Client.LoginUnsupported",
faultstring: "Missing credentials...",
},
};
}
const auth = (credentials?: Credentials) => {
const credentialsFrom = E.fromNullable(new MissingLoginTokenError());
return pipe(
credentialsFrom(credentials),
E.chain((credentials) =>
pipe(
smapiAuthTokens.verify({
token: credentials.loginToken.token,
key: credentials.loginToken.key,
}),
E.map((serviceToken) => ({
serviceToken,
credentials,
}))
)
),
E.map(({ serviceToken, credentials }) => ({
serviceToken,
credentials,
apiKey: apiKeys.mint(serviceToken),
}))
);
};
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 login = async (credentials?: Credentials) => {
const tokens = pipe(
auth(credentials),
E.getOrElseW((e) => {
throw e.toSmapiFault(smapiAuthTokens);
})
);
return musicService
.login(tokens.serviceToken)
.then((musicLibrary) => ({ ...tokens, musicLibrary }))
.catch((_) => {
throw new InvalidTokenError("Failed to login").toSmapiFault(
smapiAuthTokens
);
});
};
const soapyService = listen(
app,
@@ -410,31 +458,65 @@ function bindSmapiSoapServiceToExpress(
pollInterval: 60,
},
}),
refreshAuthToken: async (_, _2, soapyHeaders: SoapyHeaders) =>
pipe(
auth(soapyHeaders?.credentials),
E.map(({ serviceToken }) => smapiAuthTokens.issue(serviceToken)),
E.map((newToken) => ({
authToken: newToken.token,
privateKey: newToken.key,
})),
E.orElse((fault) =>
pipe(
fault.toSmapiFault(smapiAuthTokens),
E.fromPredicate(isSmapiRefreshTokenResultFault, (_) => fault),
E.map((it) => it.Fault.detail.refreshAuthTokenResult)
)
),
E.map((newToken) => ({
refreshAuthTokenResult: {
authToken: newToken.authToken,
privateKey: newToken.privateKey,
},
})),
E.getOrElseW((fault) => {
throw fault.toSmapiFault(smapiAuthTokens);
})
),
getMediaURI: async (
{ id }: { id: string },
_,
soapyHeaders: SoapyHeaders
) =>
auth(soapyHeaders?.credentials)
login(soapyHeaders?.credentials)
.then(splitId(id))
.then(({ accessToken, type, typeId }) => ({
.then(({ credentials, type, typeId }) => ({
getMediaURIResult: bonobUrl
.append({
pathname: `/stream/${type}/${typeId}`,
searchParams: { bat: accessToken },
})
.href(),
httpHeaders: [
{
httpHeader: {
header: "Authorization",
value: `Bearer ${smapiTokenAsString(
credentials.loginToken
)}`,
},
},
],
})),
getMediaMetadata: async (
{ id }: { id: string },
_,
soapyHeaders: SoapyHeaders
) =>
auth(soapyHeaders?.credentials)
login(soapyHeaders?.credentials)
.then(splitId(id))
.then(async ({ musicLibrary, accessToken, typeId }) =>
.then(async ({ musicLibrary, apiKey, typeId }) =>
musicLibrary.track(typeId!).then((it) => ({
getMediaMetadataResult: track(urlWithToken(accessToken), it),
getMediaMetadataResult: track(urlWithToken(apiKey), it),
}))
),
search: async (
@@ -442,16 +524,16 @@ function bindSmapiSoapServiceToExpress(
_,
soapyHeaders: SoapyHeaders
) =>
auth(soapyHeaders?.credentials)
login(soapyHeaders?.credentials)
.then(splitId(id))
.then(async ({ musicLibrary, accessToken }) => {
.then(async ({ musicLibrary, apiKey }) => {
switch (id) {
case "albums":
return musicLibrary.searchAlbums(term).then((it) =>
searchResult({
count: it.length,
mediaCollection: it.map((albumSummary) =>
album(urlWithToken(accessToken), albumSummary)
album(urlWithToken(apiKey), albumSummary)
),
})
);
@@ -460,7 +542,7 @@ function bindSmapiSoapServiceToExpress(
searchResult({
count: it.length,
mediaCollection: it.map((artistSummary) =>
artist(urlWithToken(accessToken), artistSummary)
artist(urlWithToken(apiKey), artistSummary)
),
})
);
@@ -469,7 +551,7 @@ function bindSmapiSoapServiceToExpress(
searchResult({
count: it.length,
mediaCollection: it.map((aTrack) =>
album(urlWithToken(accessToken), aTrack.album)
album(urlWithToken(apiKey), aTrack.album)
),
})
);
@@ -487,9 +569,9 @@ function bindSmapiSoapServiceToExpress(
_,
soapyHeaders: SoapyHeaders
) =>
auth(soapyHeaders?.credentials)
login(soapyHeaders?.credentials)
.then(splitId(id))
.then(async ({ musicLibrary, accessToken, type, typeId }) => {
.then(async ({ musicLibrary, apiKey, type, typeId }) => {
const paging = { _index: index, _count: count };
switch (type) {
case "artist":
@@ -503,7 +585,7 @@ function bindSmapiSoapServiceToExpress(
index: paging._index,
total,
mediaCollection: page.map((it) =>
album(urlWithToken(accessToken), it)
album(urlWithToken(apiKey), it)
),
relatedBrowse:
artist.similarArtists.filter((it) => it.inLibrary)
@@ -521,7 +603,7 @@ function bindSmapiSoapServiceToExpress(
case "track":
return musicLibrary.track(typeId).then((it) => ({
getExtendedMetadataResult: {
mediaMetadata: track(urlWithToken(accessToken), it),
mediaMetadata: track(urlWithToken(apiKey), it),
},
}));
case "album":
@@ -533,7 +615,7 @@ function bindSmapiSoapServiceToExpress(
userContent: false,
renameable: false,
},
...album(urlWithToken(accessToken), it),
...album(urlWithToken(apiKey), it),
},
// <mediaCollection readonly="true">
// </mediaCollection>
@@ -559,9 +641,9 @@ function bindSmapiSoapServiceToExpress(
soapyHeaders: SoapyHeaders,
{ headers }: Pick<Request, "headers">
) =>
auth(soapyHeaders?.credentials)
login(soapyHeaders?.credentials)
.then(splitId(id))
.then(({ musicLibrary, accessToken, type, typeId }) => {
.then(({ musicLibrary, apiKey, type, typeId }) => {
const paging = { _index: index, _count: count };
const acceptLanguage = headers["accept-language"];
logger.debug(
@@ -573,7 +655,7 @@ function bindSmapiSoapServiceToExpress(
musicLibrary.albums(q).then((result) => {
return getMetadataResult({
mediaCollection: result.results.map((it) =>
album(urlWithToken(accessToken), it)
album(urlWithToken(apiKey), it)
),
index: paging._index,
total: result.total,
@@ -684,7 +766,7 @@ function bindSmapiSoapServiceToExpress(
return musicLibrary.artists(paging).then((result) => {
return getMetadataResult({
mediaCollection: result.results.map((it) =>
artist(urlWithToken(accessToken), it)
artist(urlWithToken(apiKey), it)
),
index: paging._index,
total: result.total,
@@ -759,7 +841,7 @@ function bindSmapiSoapServiceToExpress(
.then(([page, total]) => {
return getMetadataResult({
mediaCollection: page.map((it) =>
playlist(urlWithToken(accessToken), it)
playlist(urlWithToken(apiKey), it)
),
index: paging._index,
total,
@@ -773,7 +855,7 @@ function bindSmapiSoapServiceToExpress(
.then(([page, total]) => {
return getMetadataResult({
mediaMetadata: page.map((it) =>
track(urlWithToken(accessToken), it)
track(urlWithToken(apiKey), it)
),
index: paging._index,
total,
@@ -787,7 +869,7 @@ function bindSmapiSoapServiceToExpress(
.then(([page, total]) => {
return getMetadataResult({
mediaCollection: page.map((it) =>
album(urlWithToken(accessToken), it)
album(urlWithToken(apiKey), it)
),
index: paging._index,
total,
@@ -804,7 +886,7 @@ function bindSmapiSoapServiceToExpress(
.then(([page, total]) => {
return getMetadataResult({
mediaCollection: page.map((it) =>
artist(urlWithToken(accessToken), it)
artist(urlWithToken(apiKey), it)
),
index: paging._index,
total,
@@ -817,7 +899,7 @@ function bindSmapiSoapServiceToExpress(
.then(([page, total]) => {
return getMetadataResult({
mediaMetadata: page.map((it) =>
track(urlWithToken(accessToken), it)
track(urlWithToken(apiKey), it)
),
index: paging._index,
total,
@@ -832,7 +914,7 @@ function bindSmapiSoapServiceToExpress(
_,
soapyHeaders: SoapyHeaders
) =>
auth(soapyHeaders?.credentials)
login(soapyHeaders?.credentials)
.then(({ musicLibrary }) =>
musicLibrary
.createPlaylist(title)
@@ -858,7 +940,7 @@ function bindSmapiSoapServiceToExpress(
_,
soapyHeaders: SoapyHeaders
) =>
auth(soapyHeaders?.credentials)
login(soapyHeaders?.credentials)
.then(({ musicLibrary }) => musicLibrary.deletePlaylist(id))
.then((_) => ({ deleteContainerResult: {} })),
addToContainer: async (
@@ -866,7 +948,7 @@ function bindSmapiSoapServiceToExpress(
_,
soapyHeaders: SoapyHeaders
) =>
auth(soapyHeaders?.credentials)
login(soapyHeaders?.credentials)
.then(splitId(id))
.then(({ musicLibrary, typeId }) =>
musicLibrary.addToPlaylist(parentId.split(":")[1]!, typeId)
@@ -877,7 +959,7 @@ function bindSmapiSoapServiceToExpress(
_,
soapyHeaders: SoapyHeaders
) =>
auth(soapyHeaders?.credentials)
login(soapyHeaders?.credentials)
.then(splitId(id))
.then((it) => ({
...it,
@@ -900,7 +982,7 @@ function bindSmapiSoapServiceToExpress(
_,
soapyHeaders: SoapyHeaders
) =>
auth(soapyHeaders?.credentials)
login(soapyHeaders?.credentials)
.then(splitId(id))
.then(({ musicLibrary, typeId }) =>
musicLibrary.rate(typeId, ratingFromInt(Math.abs(rating)))
@@ -912,7 +994,7 @@ function bindSmapiSoapServiceToExpress(
_,
soapyHeaders: SoapyHeaders
) =>
auth(soapyHeaders?.credentials)
login(soapyHeaders?.credentials)
.then(splitId(id))
.then(({ musicLibrary, type, typeId }) => {
switch (type) {

153
src/smapi_auth.ts Normal file
View File

@@ -0,0 +1,153 @@
import { Either, left, right } from "fp-ts/lib/Either";
import jwt from "jsonwebtoken";
import { v4 as uuid } from "uuid";
import { b64Decode, b64Encode } from "./b64";
import { Clock } from "./clock";
export type SmapiFault = { Fault: { faultcode: string, faultstring: string } }
export type SmapiRefreshTokenResultFault = SmapiFault & { Fault: { detail: { refreshAuthTokenResult: { authToken: string, privateKey: string } }} }
function isError(thing: any): thing is Error {
return thing.name && thing.message
}
export function isSmapiRefreshTokenResultFault(fault: SmapiFault): fault is SmapiRefreshTokenResultFault {
return (fault.Fault as any).detail?.refreshAuthTokenResult != undefined;
}
export type SmapiToken = {
token: string;
key: string;
};
export interface ToSmapiFault {
toSmapiFault(smapiAuthTokens: SmapiAuthTokens): SmapiFault
}
export class MissingLoginTokenError extends Error implements ToSmapiFault {
_tag = "MissingLoginTokenError";
constructor() {
super("Missing Login Token");
}
toSmapiFault = (_: SmapiAuthTokens) => ({
Fault: {
faultcode: "Client.LoginUnsupported",
faultstring: "Missing credentials...",
},
})
}
export class InvalidTokenError extends Error implements ToSmapiFault {
_tag = "InvalidTokenError";
constructor(message: string) {
super(message);
}
toSmapiFault = (_: SmapiAuthTokens) => ({
Fault: {
faultcode: "Client.LoginUnauthorized",
faultstring: "Failed to authenticate, try Re-Authorising your account in the sonos app",
},
})
}
export class ExpiredTokenError extends Error implements ToSmapiFault {
_tag = "ExpiredTokenError";
authToken: string;
expiredAt: number;
constructor(authToken: string, expiredAt: number) {
super("SMAPI token has expired");
this.authToken = authToken;
this.expiredAt = expiredAt;
}
toSmapiFault = (smapiAuthTokens: SmapiAuthTokens) => {
const newToken = smapiAuthTokens.issue(this.authToken)
return {
Fault: {
faultcode: "Client.TokenRefreshRequired",
faultstring: "Token has expired",
detail: {
refreshAuthTokenResult: {
authToken: newToken.token,
privateKey: newToken.key,
},
},
}
};
}
}
export function isExpiredTokenError(thing: any): thing is ExpiredTokenError {
return thing._tag == "ExpiredTokenError";
}
export type SmapiAuthTokens = {
issue: (serviceToken: string) => SmapiToken;
verify: (smapiToken: SmapiToken) => Either<ToSmapiFault, string>;
};
type TokenExpiredError = {
name: string,
message: string,
expiredAt: number
}
function isTokenExpiredError(thing: any): thing is TokenExpiredError {
return thing.name == 'TokenExpiredError';
}
export const smapiTokenAsString = (smapiToken: SmapiToken) => b64Encode(JSON.stringify({
token: smapiToken.token,
key: smapiToken.key
}));
export const smapiTokenFromString = (smapiTokenString: string): SmapiToken => JSON.parse(b64Decode(smapiTokenString));
export const SMAPI_TOKEN_VERSION = "1";
export class JWTSmapiLoginTokens implements SmapiAuthTokens {
private readonly clock: Clock;
private readonly secret: string;
private readonly expiresIn: string;
private readonly version: string;
private readonly keyGenerator: () => string;
constructor(clock: Clock, secret: string, expiresIn: string, keyGenerator: () => string = uuid, version: string = SMAPI_TOKEN_VERSION) {
this.clock = clock;
this.secret = secret;
this.expiresIn = expiresIn;
this.version = version;
this.keyGenerator = keyGenerator;
}
issue = (serviceToken: string) => {
const key = this.keyGenerator();
return {
token: jwt.sign(
{ serviceToken, iat: this.clock.now().unix() },
this.secret + this.version + key,
{ expiresIn: this.expiresIn }
),
key,
};
};
verify = (smapiToken: SmapiToken): Either<ToSmapiFault, string> => {
try {
return right((jwt.verify(smapiToken.token, this.secret + this.version + smapiToken.key) as any).serviceToken);
} catch (e) {
if(isTokenExpiredError(e)) {
const x = ((jwt.verify(smapiToken.token, this.secret + this.version + smapiToken.key, { ignoreExpiration: true })) as any).serviceToken;
return left(new ExpiredTokenError(x, e.expiredAt))
} else if(isError(e))
return left(new InvalidTokenError(e.message));
else
return left(new InvalidTokenError("Failed to verify token"))
}
};
}

View File

@@ -443,7 +443,7 @@ export class Subsonic implements MusicService {
generateToken = async (credentials: Credentials) =>
this.getJSON(credentials, "/rest/ping.view")
.then(() => ({
authToken: b64Encode(JSON.stringify(credentials)),
serviceToken: b64Encode(JSON.stringify(credentials)),
userId: credentials.username,
nickname: credentials.username,
}))