mirror of
https://github.com/wkulhanek/bonob.git
synced 2025-12-21 17:33:29 +01:00
Refreshing bearer tokens when smapi token is refreshed (#85)
This commit is contained in:
@@ -48,6 +48,7 @@ const subsonic = new Subsonic(
|
||||
|
||||
const featureFlagAwareMusicService: MusicService = {
|
||||
generateToken: subsonic.generateToken,
|
||||
refreshToken: subsonic.refreshToken,
|
||||
login: (serviceToken: string) =>
|
||||
subsonic.login(serviceToken).then((library) => {
|
||||
return {
|
||||
|
||||
@@ -1,27 +1,18 @@
|
||||
import { BUrn } from "./burn";
|
||||
import { taskEither as TE } from "fp-ts";
|
||||
|
||||
export type Credentials = { username: string; password: string };
|
||||
|
||||
export function isSuccess(
|
||||
authResult: AuthSuccess | AuthFailure
|
||||
): authResult is AuthSuccess {
|
||||
return (authResult as AuthSuccess).serviceToken !== undefined;
|
||||
}
|
||||
|
||||
export function isFailure(
|
||||
authResult: any | AuthFailure
|
||||
): authResult is AuthFailure {
|
||||
return (authResult as AuthFailure).message !== undefined;
|
||||
}
|
||||
|
||||
export type AuthSuccess = {
|
||||
serviceToken: string;
|
||||
userId: string;
|
||||
nickname: string;
|
||||
};
|
||||
|
||||
export type AuthFailure = {
|
||||
message: string;
|
||||
export class AuthFailure extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
}
|
||||
};
|
||||
|
||||
export type ArtistSummary = {
|
||||
@@ -155,7 +146,8 @@ export const asArtistAlbumPairs = (artists: Artist[]): [Artist, Album][] =>
|
||||
);
|
||||
|
||||
export interface MusicService {
|
||||
generateToken(credentials: Credentials): Promise<AuthSuccess | AuthFailure>;
|
||||
generateToken(credentials: Credentials): TE.TaskEither<AuthFailure, AuthSuccess>;
|
||||
refreshToken(serviceToken: string): TE.TaskEither<AuthFailure, AuthSuccess>;
|
||||
login(serviceToken: string): Promise<MusicLibrary>;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { either as E } from "fp-ts";
|
||||
import { either as E, taskEither as TE } from "fp-ts";
|
||||
import express, { Express, Request } from "express";
|
||||
import * as Eta from "eta";
|
||||
import path from "path";
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
ratingAsInt,
|
||||
} from "./smapi";
|
||||
import { LinkCodes, InMemoryLinkCodes } from "./link_codes";
|
||||
import { MusicService, isSuccess } from "./music_service";
|
||||
import { MusicService, AuthFailure, AuthSuccess } from "./music_service";
|
||||
import bindSmapiSoapServiceToExpress from "./smapi";
|
||||
import { APITokens, InMemoryAPITokens } from "./api_tokens";
|
||||
import logger from "./logger";
|
||||
@@ -36,7 +36,11 @@ import morgan from "morgan";
|
||||
import { takeWithRepeats } from "./utils";
|
||||
import { parse } from "./burn";
|
||||
import { axiosImageFetcher, ImageFetcher } from "./subsonic";
|
||||
import { JWTSmapiLoginTokens, SmapiAuthTokens, smapiTokenFromString } from "./smapi_auth";
|
||||
import {
|
||||
JWTSmapiLoginTokens,
|
||||
SmapiAuthTokens,
|
||||
smapiTokenFromString,
|
||||
} from "./smapi_auth";
|
||||
|
||||
export const BONOB_ACCESS_TOKEN_HEADER = "bat";
|
||||
|
||||
@@ -233,33 +237,36 @@ function server(
|
||||
message: lang("invalidLinkCode"),
|
||||
});
|
||||
} else {
|
||||
return musicService
|
||||
.generateToken({
|
||||
return pipe(
|
||||
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", {
|
||||
}),
|
||||
TE.match(
|
||||
(e: AuthFailure) => ({
|
||||
status: 403,
|
||||
template: "failure",
|
||||
params: {
|
||||
lang,
|
||||
message: lang("loginFailed"),
|
||||
cause: authResult.message,
|
||||
});
|
||||
cause: e.message,
|
||||
},
|
||||
}),
|
||||
(success: AuthSuccess) => {
|
||||
linkCodes.associate(linkCode, success);
|
||||
return {
|
||||
status: 200,
|
||||
template: "success",
|
||||
params: {
|
||||
lang,
|
||||
message: lang("loginSuccessful"),
|
||||
},
|
||||
};
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
return res.status(403).render("failure", {
|
||||
lang,
|
||||
message: lang("loginFailed"),
|
||||
cause: `Unexpected error occured - ${e}`,
|
||||
});
|
||||
});
|
||||
)
|
||||
)().then(({ status, template, params }) =>
|
||||
res.status(status).render(template, params)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -371,22 +378,26 @@ function server(
|
||||
logger.info(
|
||||
`${trace} bnb<- ${req.method} ${req.path}?${JSON.stringify(
|
||||
req.query
|
||||
)}, headers=${JSON.stringify({ ...req.headers, "authorization": "*****" })}`
|
||||
)}, headers=${JSON.stringify({ ...req.headers, authorization: "*****" })}`
|
||||
);
|
||||
|
||||
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(smapiTokenFromString(bearerToken)),
|
||||
E.mapLeft(_ => "Bearer token failed to verify")
|
||||
)),
|
||||
E.chain((authorization) =>
|
||||
pipe(
|
||||
authorization.match(/Bearer (?<token>.*)/),
|
||||
bearerToken,
|
||||
E.map((match) => match[1]!)
|
||||
)
|
||||
),
|
||||
E.chain((bearerToken) =>
|
||||
pipe(
|
||||
smapiAuthTokens.verify(smapiTokenFromString(bearerToken)),
|
||||
E.mapLeft((_) => "Bearer token failed to verify")
|
||||
)
|
||||
),
|
||||
E.getOrElseW(() => undefined)
|
||||
);
|
||||
|
||||
|
||||
117
src/smapi.ts
117
src/smapi.ts
@@ -3,7 +3,7 @@ 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 { option as O, either as E, taskEither as TE, task as T } from "fp-ts";
|
||||
import { pipe } from "fp-ts/lib/function";
|
||||
|
||||
import logger from "./logger";
|
||||
@@ -29,11 +29,12 @@ import { ICON, iconForGenre } from "./icon";
|
||||
import _, { uniq } from "underscore";
|
||||
import { BUrn, formatForURL } from "./burn";
|
||||
import {
|
||||
InvalidTokenError,
|
||||
isSmapiRefreshTokenResultFault,
|
||||
isExpiredTokenError,
|
||||
MissingLoginTokenError,
|
||||
SmapiAuthTokens,
|
||||
smapiTokenAsString,
|
||||
SMAPI_FAULT_LOGIN_UNAUTHORIZED,
|
||||
ToSmapiFault,
|
||||
} from "./smapi_auth";
|
||||
|
||||
export const LOGIN_ROUTE = "/login";
|
||||
@@ -379,6 +380,16 @@ type SoapyHeaders = {
|
||||
credentials?: Credentials;
|
||||
};
|
||||
|
||||
type Auth = {
|
||||
serviceToken: string;
|
||||
credentials: Credentials;
|
||||
apiKey: string;
|
||||
};
|
||||
|
||||
function isAuth(thing: any): thing is Auth {
|
||||
return thing.serviceToken;
|
||||
}
|
||||
|
||||
function bindSmapiSoapServiceToExpress(
|
||||
app: Express,
|
||||
soapPath: string,
|
||||
@@ -399,7 +410,7 @@ function bindSmapiSoapServiceToExpress(
|
||||
},
|
||||
});
|
||||
|
||||
const auth = (credentials?: Credentials) => {
|
||||
const auth = (credentials?: Credentials): E.Either<ToSmapiFault, Auth> => {
|
||||
const credentialsFrom = E.fromNullable(new MissingLoginTokenError());
|
||||
return pipe(
|
||||
credentialsFrom(credentials),
|
||||
@@ -424,21 +435,40 @@ function bindSmapiSoapServiceToExpress(
|
||||
};
|
||||
|
||||
const login = async (credentials?: Credentials) => {
|
||||
const tokens = pipe(
|
||||
const authOrFail = pipe(
|
||||
auth(credentials),
|
||||
E.getOrElseW((e) => {
|
||||
throw e.toSmapiFault(smapiAuthTokens);
|
||||
})
|
||||
E.getOrElseW((fault) => fault)
|
||||
);
|
||||
|
||||
return musicService
|
||||
.login(tokens.serviceToken)
|
||||
.then((musicLibrary) => ({ ...tokens, musicLibrary }))
|
||||
.catch((_) => {
|
||||
throw new InvalidTokenError("Failed to login").toSmapiFault(
|
||||
smapiAuthTokens
|
||||
);
|
||||
});
|
||||
if (isAuth(authOrFail)) {
|
||||
return musicService
|
||||
.login(authOrFail.serviceToken)
|
||||
.then((musicLibrary) => ({ ...authOrFail, musicLibrary }))
|
||||
.catch((_) => {
|
||||
throw SMAPI_FAULT_LOGIN_UNAUTHORIZED;
|
||||
});
|
||||
} else if (isExpiredTokenError(authOrFail)) {
|
||||
throw await pipe(
|
||||
musicService.refreshToken(authOrFail.expiredToken),
|
||||
TE.map((it) => smapiAuthTokens.issue(it.serviceToken)),
|
||||
TE.map((newToken) => ({
|
||||
Fault: {
|
||||
faultcode: "Client.TokenRefreshRequired",
|
||||
faultstring: "Token has expired",
|
||||
detail: {
|
||||
refreshAuthTokenResult: {
|
||||
authToken: newToken.token,
|
||||
privateKey: newToken.key,
|
||||
},
|
||||
},
|
||||
},
|
||||
})),
|
||||
TE.getOrElse(() =>
|
||||
T.of(SMAPI_FAULT_LOGIN_UNAUTHORIZED)
|
||||
)
|
||||
)();
|
||||
} else {
|
||||
throw authOrFail.toSmapiFault();
|
||||
}
|
||||
};
|
||||
|
||||
const soapyService = listen(
|
||||
@@ -458,31 +488,34 @@ 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);
|
||||
})
|
||||
),
|
||||
refreshAuthToken: async (_, _2, soapyHeaders: SoapyHeaders) => {
|
||||
const serviceToken = pipe(
|
||||
auth(soapyHeaders?.credentials),
|
||||
E.fold(
|
||||
(fault) =>
|
||||
isExpiredTokenError(fault)
|
||||
? E.right(fault.expiredToken)
|
||||
: E.left(fault),
|
||||
(creds) => E.right(creds.serviceToken)
|
||||
),
|
||||
E.getOrElseW((fault) => {
|
||||
throw fault.toSmapiFault();
|
||||
})
|
||||
);
|
||||
return pipe(
|
||||
musicService.refreshToken(serviceToken),
|
||||
TE.map((it) => smapiAuthTokens.issue(it.serviceToken)),
|
||||
TE.map((it) => ({
|
||||
refreshAuthTokenResult: {
|
||||
authToken: it.token,
|
||||
privateKey: it.key,
|
||||
},
|
||||
})),
|
||||
TE.getOrElse((_) => {
|
||||
throw SMAPI_FAULT_LOGIN_UNAUTHORIZED;
|
||||
})
|
||||
)();
|
||||
},
|
||||
getMediaURI: async (
|
||||
{ id }: { id: string },
|
||||
_,
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
import { Either, left, right } from "fp-ts/lib/Either";
|
||||
import { either as E } from "fp-ts";
|
||||
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 } }} }
|
||||
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
|
||||
return thing.name && thing.message;
|
||||
}
|
||||
|
||||
export function isSmapiRefreshTokenResultFault(fault: SmapiFault): fault is SmapiRefreshTokenResultFault {
|
||||
export function isSmapiRefreshTokenResultFault(
|
||||
fault: SmapiFault
|
||||
): fault is SmapiRefreshTokenResultFault {
|
||||
return (fault.Fault as any).detail?.refreshAuthTokenResult != undefined;
|
||||
}
|
||||
|
||||
@@ -21,9 +29,25 @@ export type SmapiToken = {
|
||||
};
|
||||
|
||||
export interface ToSmapiFault {
|
||||
toSmapiFault(smapiAuthTokens: SmapiAuthTokens): SmapiFault
|
||||
_tag: string;
|
||||
toSmapiFault(): SmapiFault
|
||||
}
|
||||
|
||||
export const SMAPI_FAULT_LOGIN_UNAUTHORIZED = {
|
||||
Fault: {
|
||||
faultcode: "Client.LoginUnauthorized",
|
||||
faultstring:
|
||||
"Failed to authenticate, try Re-Authorising your account in the sonos app",
|
||||
},
|
||||
};
|
||||
|
||||
export const SMAPI_FAULT_LOGIN_UNSUPPORTED = {
|
||||
Fault: {
|
||||
faultcode: "Client.LoginUnsupported",
|
||||
faultstring: "Missing credentials...",
|
||||
},
|
||||
};
|
||||
|
||||
export class MissingLoginTokenError extends Error implements ToSmapiFault {
|
||||
_tag = "MissingLoginTokenError";
|
||||
|
||||
@@ -31,12 +55,7 @@ export class MissingLoginTokenError extends Error implements ToSmapiFault {
|
||||
super("Missing Login Token");
|
||||
}
|
||||
|
||||
toSmapiFault = (_: SmapiAuthTokens) => ({
|
||||
Fault: {
|
||||
faultcode: "Client.LoginUnsupported",
|
||||
faultstring: "Missing credentials...",
|
||||
},
|
||||
})
|
||||
toSmapiFault = () => SMAPI_FAULT_LOGIN_UNSUPPORTED;
|
||||
}
|
||||
|
||||
|
||||
@@ -47,66 +66,54 @@ export class InvalidTokenError extends Error implements ToSmapiFault {
|
||||
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";
|
||||
serviceToken: string;
|
||||
expiredAt: number;
|
||||
|
||||
constructor(serviceToken: string, expiredAt: number) {
|
||||
super("SMAPI token has expired");
|
||||
this.serviceToken = serviceToken;
|
||||
this.expiredAt = expiredAt;
|
||||
}
|
||||
|
||||
toSmapiFault = (smapiAuthTokens: SmapiAuthTokens) => {
|
||||
const newToken = smapiAuthTokens.issue(this.serviceToken)
|
||||
return {
|
||||
Fault: {
|
||||
faultcode: "Client.TokenRefreshRequired",
|
||||
faultstring: "Token has expired",
|
||||
detail: {
|
||||
refreshAuthTokenResult: {
|
||||
authToken: newToken.token,
|
||||
privateKey: newToken.key,
|
||||
},
|
||||
},
|
||||
}
|
||||
};
|
||||
}
|
||||
toSmapiFault = () => SMAPI_FAULT_LOGIN_UNAUTHORIZED;
|
||||
}
|
||||
|
||||
export function isExpiredTokenError(thing: any): thing is ExpiredTokenError {
|
||||
return thing._tag == "ExpiredTokenError";
|
||||
}
|
||||
|
||||
export class ExpiredTokenError extends Error implements ToSmapiFault {
|
||||
_tag = "ExpiredTokenError";
|
||||
expiredToken: string;
|
||||
|
||||
constructor(expiredToken: string) {
|
||||
super("SMAPI token has expired");
|
||||
this.expiredToken = expiredToken;
|
||||
}
|
||||
|
||||
toSmapiFault = () => ({
|
||||
Fault: {
|
||||
faultcode: "Client.TokenRefreshRequired",
|
||||
faultstring: "Token has expired",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export type SmapiAuthTokens = {
|
||||
issue: (serviceToken: string) => SmapiToken;
|
||||
verify: (smapiToken: SmapiToken) => Either<ToSmapiFault, string>;
|
||||
verify: (smapiToken: SmapiToken) => E.Either<ToSmapiFault, string>;
|
||||
};
|
||||
|
||||
type TokenExpiredError = {
|
||||
name: string,
|
||||
message: string,
|
||||
expiredAt: number
|
||||
}
|
||||
name: string;
|
||||
message: string;
|
||||
expiredAt: number;
|
||||
};
|
||||
|
||||
function isTokenExpiredError(thing: any): thing is TokenExpiredError {
|
||||
return thing.name == '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 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 = 2;
|
||||
|
||||
@@ -117,7 +124,13 @@ export class JWTSmapiLoginTokens implements SmapiAuthTokens {
|
||||
private readonly version: number;
|
||||
private readonly keyGenerator: () => string;
|
||||
|
||||
constructor(clock: Clock, secret: string, expiresIn: string, keyGenerator: () => string = uuid, version: number = SMAPI_TOKEN_VERSION) {
|
||||
constructor(
|
||||
clock: Clock,
|
||||
secret: string,
|
||||
expiresIn: string,
|
||||
keyGenerator: () => string = uuid,
|
||||
version: number = SMAPI_TOKEN_VERSION
|
||||
) {
|
||||
this.clock = clock;
|
||||
this.secret = secret;
|
||||
this.expiresIn = expiresIn;
|
||||
@@ -137,17 +150,28 @@ export class JWTSmapiLoginTokens implements SmapiAuthTokens {
|
||||
};
|
||||
};
|
||||
|
||||
verify = (smapiToken: SmapiToken): Either<ToSmapiFault, string> => {
|
||||
verify = (smapiToken: SmapiToken): E.Either<ToSmapiFault, string> => {
|
||||
try {
|
||||
return right((jwt.verify(smapiToken.token, this.secret + this.version + smapiToken.key) as any).serviceToken);
|
||||
return E.right(
|
||||
(
|
||||
jwt.verify(
|
||||
smapiToken.token,
|
||||
this.secret + this.version + smapiToken.key
|
||||
) as any
|
||||
).serviceToken
|
||||
);
|
||||
} catch (e) {
|
||||
if(isTokenExpiredError(e)) {
|
||||
const serviceToken = ((jwt.verify(smapiToken.token, this.secret + this.version + smapiToken.key, { ignoreExpiration: true })) as any).serviceToken;
|
||||
return left(new ExpiredTokenError(serviceToken, e.expiredAt))
|
||||
} else if(isError(e))
|
||||
return left(new InvalidTokenError(e.message));
|
||||
else
|
||||
return left(new InvalidTokenError("Failed to verify token"))
|
||||
if (isTokenExpiredError(e)) {
|
||||
const serviceToken = (
|
||||
jwt.verify(
|
||||
smapiToken.token,
|
||||
this.secret + this.version + smapiToken.key,
|
||||
{ ignoreExpiration: true }
|
||||
) as any
|
||||
).serviceToken;
|
||||
return E.left(new ExpiredTokenError(serviceToken));
|
||||
} else if (isError(e)) return E.left(new InvalidTokenError(e.message));
|
||||
else return E.left(new InvalidTokenError("Failed to verify token"));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
121
src/subsonic.ts
121
src/subsonic.ts
@@ -1,4 +1,4 @@
|
||||
import { option as O } from "fp-ts";
|
||||
import { option as O, taskEither as TE } from "fp-ts";
|
||||
import * as A from "fp-ts/Array";
|
||||
import { ordString } from "fp-ts/lib/Ord";
|
||||
import { pipe } from "fp-ts/lib/function";
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
Rating,
|
||||
AlbumQueryType,
|
||||
Artist,
|
||||
AuthFailure,
|
||||
} from "./music_service";
|
||||
import sharp from "sharp";
|
||||
import _ from "underscore";
|
||||
@@ -209,11 +210,11 @@ type GetStarredResponse = {
|
||||
};
|
||||
|
||||
export type PingResponse = {
|
||||
status: string,
|
||||
version: string,
|
||||
type: string,
|
||||
serverVersion: string
|
||||
}
|
||||
status: string;
|
||||
version: string;
|
||||
type: string;
|
||||
serverVersion: string;
|
||||
};
|
||||
|
||||
type Search3Response = SubsonicResponse & {
|
||||
searchResult3: {
|
||||
@@ -234,12 +235,13 @@ type IdName = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
const coverArtURN = (coverArt: string | undefined): BUrn | undefined => pipe(
|
||||
coverArt,
|
||||
O.fromNullable,
|
||||
O.map((it: string) => ({ system: "subsonic", resource: `art:${it}` })),
|
||||
O.getOrElseW(() => undefined)
|
||||
)
|
||||
const coverArtURN = (coverArt: string | undefined): BUrn | undefined =>
|
||||
pipe(
|
||||
coverArt,
|
||||
O.fromNullable,
|
||||
O.map((it: string) => ({ system: "subsonic", resource: `art:${it}` })),
|
||||
O.getOrElseW(() => undefined)
|
||||
);
|
||||
|
||||
export const artistImageURN = (
|
||||
spec: Partial<{
|
||||
@@ -255,7 +257,7 @@ export const artistImageURN = (
|
||||
if (deets.artistImageURL && isValidImage(deets.artistImageURL)) {
|
||||
return {
|
||||
system: "external",
|
||||
resource: deets.artistImageURL
|
||||
resource: deets.artistImageURL,
|
||||
};
|
||||
} else if (artistIsInLibrary(deets.artistId)) {
|
||||
return {
|
||||
@@ -279,7 +281,9 @@ export const asTrack = (album: Album, song: song): Track => ({
|
||||
artist: {
|
||||
id: song.artistId,
|
||||
name: song.artist ? song.artist : "?",
|
||||
image: song.artistId ? artistImageURN({ artistId: song.artistId }) : undefined,
|
||||
image: song.artistId
|
||||
? artistImageURN({ artistId: song.artistId })
|
||||
: undefined,
|
||||
},
|
||||
rating: {
|
||||
love: song.starred != undefined,
|
||||
@@ -390,14 +394,21 @@ const AlbumQueryTypeToSubsonicType: Record<AlbumQueryType, string> = {
|
||||
const artistIsInLibrary = (artistId: string | undefined) =>
|
||||
artistId != undefined && artistId != "-1";
|
||||
|
||||
type SubsonicCredentials = Credentials & { type: string, bearer: string | undefined }
|
||||
type SubsonicCredentials = Credentials & {
|
||||
type: string;
|
||||
bearer: string | undefined;
|
||||
};
|
||||
|
||||
export const asToken = (credentials: SubsonicCredentials) => b64Encode(JSON.stringify(credentials))
|
||||
export const parseToken = (token: string): SubsonicCredentials => JSON.parse(b64Decode(token));
|
||||
export const asToken = (credentials: SubsonicCredentials) =>
|
||||
b64Encode(JSON.stringify(credentials));
|
||||
export const parseToken = (token: string): SubsonicCredentials =>
|
||||
JSON.parse(b64Decode(token));
|
||||
|
||||
interface SubsonicMusicLibrary extends MusicLibrary {
|
||||
flavour(): string
|
||||
bearerToken(): Promise<string | undefined>
|
||||
flavour(): string;
|
||||
bearerToken(
|
||||
credentials: Credentials
|
||||
): TE.TaskEither<Error, string | undefined>;
|
||||
}
|
||||
|
||||
export class Subsonic implements MusicService {
|
||||
@@ -457,16 +468,40 @@ export class Subsonic implements MusicService {
|
||||
else return json as unknown as T;
|
||||
});
|
||||
|
||||
generateToken = async (credentials: Credentials) =>
|
||||
this.getJSON<PingResponse>(credentials, "/rest/ping.view")
|
||||
.then(({ type }) => this.libraryFor({ ...credentials, type }).then(library => ({ type, library })))
|
||||
.then(({ library, type }) => library.bearerToken().then(bearer => ({ bearer, type })))
|
||||
.then(({ bearer, type }) => ({
|
||||
generateToken = (credentials: Credentials) =>
|
||||
pipe(
|
||||
TE.tryCatch(
|
||||
() =>
|
||||
this.getJSON<PingResponse>(
|
||||
_.pick(credentials, "username", "password"),
|
||||
"/rest/ping.view"
|
||||
),
|
||||
(e) => new AuthFailure(e as string)
|
||||
),
|
||||
TE.chain(({ type }) =>
|
||||
pipe(
|
||||
TE.tryCatch(
|
||||
() => this.libraryFor({ ...credentials, type }),
|
||||
() => new AuthFailure("Failed to get library")
|
||||
),
|
||||
TE.map((library) => ({ type, library }))
|
||||
)
|
||||
),
|
||||
TE.chain(({ library, type }) =>
|
||||
pipe(
|
||||
library.bearerToken(credentials),
|
||||
TE.map((bearer) => ({ bearer, type }))
|
||||
)
|
||||
),
|
||||
TE.map(({ bearer, type }) => ({
|
||||
serviceToken: asToken({ ...credentials, bearer, type }),
|
||||
userId: credentials.username,
|
||||
nickname: credentials.username,
|
||||
}))
|
||||
.catch((e) => ({ message: `${e}` }));
|
||||
);
|
||||
|
||||
refreshToken = (serviceToken: string) =>
|
||||
this.generateToken(parseToken(serviceToken));
|
||||
|
||||
getArtists = (
|
||||
credentials: Credentials
|
||||
@@ -639,14 +674,16 @@ export class Subsonic implements MusicService {
|
||||
// albums: it.album.map(asAlbum),
|
||||
// }));
|
||||
|
||||
login = async (token: string) => this.libraryFor(parseToken(token))
|
||||
login = async (token: string) => this.libraryFor(parseToken(token));
|
||||
|
||||
private libraryFor = (credentials: Credentials & { type: string }) => {
|
||||
private libraryFor = (
|
||||
credentials: Credentials & { type: string }
|
||||
): Promise<SubsonicMusicLibrary> => {
|
||||
const subsonic = this;
|
||||
|
||||
const genericSubsonic: SubsonicMusicLibrary = {
|
||||
flavour: () => "subsonic",
|
||||
bearerToken: () => Promise.resolve(undefined),
|
||||
bearerToken: (_: Credentials) => TE.right(undefined),
|
||||
artists: (q: ArtistQuery): Promise<Result<ArtistSummary>> =>
|
||||
subsonic
|
||||
.getArtists(credentials)
|
||||
@@ -766,13 +803,7 @@ export class Subsonic implements MusicService {
|
||||
Promise.resolve(coverArtURN)
|
||||
.then((it) => assertSystem(it, "subsonic"))
|
||||
.then((it) => it.resource.split(":")[1]!)
|
||||
.then((it) =>
|
||||
subsonic.getCoverArt(
|
||||
credentials,
|
||||
it,
|
||||
size
|
||||
)
|
||||
)
|
||||
.then((it) => subsonic.getCoverArt(credentials, it, size))
|
||||
.then((res) => ({
|
||||
contentType: res.headers["content-type"],
|
||||
data: Buffer.from(res.data, "binary"),
|
||||
@@ -923,13 +954,25 @@ export class Subsonic implements MusicService {
|
||||
),
|
||||
};
|
||||
|
||||
if(credentials.type == "navidrome") {
|
||||
if (credentials.type == "navidrome") {
|
||||
return Promise.resolve({
|
||||
...genericSubsonic,
|
||||
flavour: ()=> "navidrome"
|
||||
})
|
||||
flavour: () => "navidrome",
|
||||
bearerToken: (credentials: Credentials) =>
|
||||
pipe(
|
||||
TE.tryCatch(
|
||||
() =>
|
||||
axios.post(
|
||||
`${this.url}/auth/login`,
|
||||
_.pick(credentials, "username", "password")
|
||||
),
|
||||
() => new AuthFailure("Failed to get bearerToken")
|
||||
),
|
||||
TE.map((it) => it.data.token as string | undefined)
|
||||
),
|
||||
});
|
||||
} else {
|
||||
return Promise.resolve(genericSubsonic);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { taskEither as TE } from "fp-ts";
|
||||
import { pipe } from "fp-ts/lib/function";
|
||||
|
||||
import { InMemoryMusicService } from "./in_memory_music_service";
|
||||
import {
|
||||
AuthSuccess,
|
||||
MusicLibrary,
|
||||
artistToArtistSummary,
|
||||
albumToAlbumSummary,
|
||||
@@ -28,7 +30,10 @@ describe("InMemoryMusicService", () => {
|
||||
|
||||
service.hasUser(credentials);
|
||||
|
||||
const token = (await service.generateToken(credentials)) as AuthSuccess;
|
||||
const token = await pipe(
|
||||
service.generateToken(credentials),
|
||||
TE.getOrElse(e => { throw e })
|
||||
)();
|
||||
|
||||
expect(token.userId).toEqual(credentials.username);
|
||||
expect(token.nickname).toEqual(credentials.username);
|
||||
@@ -43,7 +48,10 @@ describe("InMemoryMusicService", () => {
|
||||
|
||||
service.hasUser(credentials);
|
||||
|
||||
const token = (await service.generateToken(credentials)) as AuthSuccess;
|
||||
const token = await pipe(
|
||||
service.generateToken(credentials),
|
||||
TE.getOrElse(e => { throw e })
|
||||
)();
|
||||
|
||||
service.clear();
|
||||
|
||||
@@ -62,7 +70,11 @@ describe("InMemoryMusicService", () => {
|
||||
|
||||
service.hasUser(user);
|
||||
|
||||
const token = (await service.generateToken(user)) as AuthSuccess;
|
||||
const token = await pipe(
|
||||
service.generateToken(user),
|
||||
TE.getOrElse(e => { throw e })
|
||||
)();
|
||||
|
||||
musicLibrary = (await service.login(token.serviceToken)) as MusicLibrary;
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { option as O } from "fp-ts";
|
||||
import { option as O, taskEither as TE } from "fp-ts";
|
||||
import * as A from "fp-ts/Array";
|
||||
import { fromEquals } from "fp-ts/lib/Eq";
|
||||
import { pipe } from "fp-ts/lib/function";
|
||||
@@ -34,23 +34,27 @@ export class InMemoryMusicService implements MusicService {
|
||||
generateToken({
|
||||
username,
|
||||
password,
|
||||
}: Credentials): Promise<AuthSuccess | AuthFailure> {
|
||||
}: Credentials): TE.TaskEither<AuthFailure, AuthSuccess> {
|
||||
if (
|
||||
username != undefined &&
|
||||
password != undefined &&
|
||||
this.users[username] == password
|
||||
) {
|
||||
return Promise.resolve({
|
||||
return TE.right({
|
||||
serviceToken: b64Encode(JSON.stringify({ username, password })),
|
||||
userId: username,
|
||||
nickname: username,
|
||||
type: "in-memory"
|
||||
});
|
||||
} else {
|
||||
return Promise.resolve({ message: `Invalid user:${username}` });
|
||||
return TE.left(new AuthFailure(`Invalid user:${username}`));
|
||||
}
|
||||
}
|
||||
|
||||
refreshToken(serviceToken: string): TE.TaskEither<AuthFailure, AuthSuccess> {
|
||||
return this.generateToken(JSON.parse(b64Decode(serviceToken)))
|
||||
}
|
||||
|
||||
login(serviceToken: string): Promise<MusicLibrary> {
|
||||
const credentials = JSON.parse(b64Decode(serviceToken)) as Credentials;
|
||||
if (this.users[credentials.username] != credentials.password)
|
||||
|
||||
@@ -3,10 +3,10 @@ import dayjs from "dayjs";
|
||||
import request from "supertest";
|
||||
import Image from "image-js";
|
||||
import fs from "fs";
|
||||
import { either as E } from "fp-ts";
|
||||
import { either as E, taskEither as TE } from "fp-ts";
|
||||
import path from "path";
|
||||
|
||||
import { MusicService } from "../src/music_service";
|
||||
import { AuthFailure, MusicService } from "../src/music_service";
|
||||
import makeServer, {
|
||||
BONOB_ACCESS_TOKEN_HEADER,
|
||||
RangeBytesFromFilter,
|
||||
@@ -637,7 +637,7 @@ describe("server", () => {
|
||||
};
|
||||
|
||||
linkCodes.has.mockReturnValue(true);
|
||||
musicService.generateToken.mockResolvedValue(authSuccess);
|
||||
musicService.generateToken.mockReturnValue(TE.right(authSuccess))
|
||||
linkCodes.associate.mockReturnValue(true);
|
||||
|
||||
const res = await request(server)
|
||||
@@ -669,7 +669,7 @@ describe("server", () => {
|
||||
const message = `Invalid user:${username}`;
|
||||
|
||||
linkCodes.has.mockReturnValue(true);
|
||||
musicService.generateToken.mockResolvedValue({ message });
|
||||
musicService.generateToken.mockReturnValue(TE.left(new AuthFailure(message)))
|
||||
|
||||
const res = await request(server)
|
||||
.post(bonobUrl.append({ pathname: "/login" }).pathname())
|
||||
@@ -683,27 +683,6 @@ describe("server", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("when an unexpected failure occurs", () => {
|
||||
it("should return 403 with message", async () => {
|
||||
const username = "userDoesntExist";
|
||||
const password = "password";
|
||||
const linkCode = uuid();
|
||||
|
||||
linkCodes.has.mockReturnValue(true);
|
||||
musicService.generateToken.mockRejectedValue("BOOOOOOM");
|
||||
|
||||
const res = await request(server)
|
||||
.post(bonobUrl.append({ pathname: "/login" }).pathname())
|
||||
.set("accept-language", acceptLanguage)
|
||||
.type("form")
|
||||
.send({ username, password, linkCode })
|
||||
.expect(403);
|
||||
|
||||
expect(res.text).toContain(lang("loginFailed"));
|
||||
expect(res.text).toContain('Unexpected error occured - BOOOOOOM');
|
||||
});
|
||||
});
|
||||
|
||||
describe("when linkCode is invalid", () => {
|
||||
it("should return 400 with message", async () => {
|
||||
const username = "jane";
|
||||
@@ -777,7 +756,7 @@ describe("server", () => {
|
||||
|
||||
describe("when the Bearer token has expired", () => {
|
||||
it("should return a 401", async () => {
|
||||
smapiAuthTokens.verify.mockReturnValue(E.left(new ExpiredTokenError(serviceToken, 0)))
|
||||
smapiAuthTokens.verify.mockReturnValue(E.left(new ExpiredTokenError(serviceToken)))
|
||||
|
||||
const res = await request(server).head(
|
||||
bonobUrl
|
||||
@@ -865,7 +844,7 @@ describe("server", () => {
|
||||
|
||||
describe("when the Bearer token has expired", () => {
|
||||
it("should return a 401", async () => {
|
||||
smapiAuthTokens.verify.mockReturnValue(E.left(new ExpiredTokenError(serviceToken, 0)))
|
||||
smapiAuthTokens.verify.mockReturnValue(E.left(new ExpiredTokenError(serviceToken)))
|
||||
|
||||
const res = await request(server)
|
||||
.get(
|
||||
|
||||
@@ -2,7 +2,7 @@ import crypto from "crypto";
|
||||
import request from "supertest";
|
||||
import { Client, createClientAsync } from "soap";
|
||||
import { v4 as uuid } from "uuid";
|
||||
import { either as E } from "fp-ts";
|
||||
import { either as E, taskEither as TE } from "fp-ts";
|
||||
import { DOMParserImpl } from "xmldom-ts";
|
||||
import * as xpath from "xpath-ts";
|
||||
import { randomInt } from "crypto";
|
||||
@@ -861,6 +861,7 @@ describe("defaultArtistArtURI", () => {
|
||||
describe("wsdl api", () => {
|
||||
const musicService = {
|
||||
generateToken: jest.fn(),
|
||||
refreshToken: jest.fn(),
|
||||
login: jest.fn(),
|
||||
};
|
||||
const linkCodes = {
|
||||
@@ -1079,10 +1080,11 @@ describe("wsdl api", () => {
|
||||
|
||||
describe("when token has expired", () => {
|
||||
it("should return a refreshed auth token", async () => {
|
||||
const oneDayAgo = clock.time.subtract(1, "d");
|
||||
const refreshedServiceToken = `refreshedServiceToken-${uuid()}`
|
||||
const newSmapiAuthToken = { token: `newToken-${uuid()}`, key: `newKey-${uuid()}` };
|
||||
|
||||
smapiAuthTokens.verify.mockReturnValue(E.left(new ExpiredTokenError(serviceToken, oneDayAgo.unix())));
|
||||
smapiAuthTokens.verify.mockReturnValue(E.left(new ExpiredTokenError(serviceToken)));
|
||||
musicService.refreshToken.mockReturnValue(TE.right({ serviceToken: refreshedServiceToken }));
|
||||
smapiAuthTokens.issue.mockReturnValue(newSmapiAuthToken);
|
||||
|
||||
const ws = await createClientAsync(`${service.uri}?wsdl`, {
|
||||
@@ -1101,6 +1103,9 @@ describe("wsdl api", () => {
|
||||
privateKey: newSmapiAuthToken.key,
|
||||
},
|
||||
});
|
||||
|
||||
expect(musicService.refreshToken).toHaveBeenCalledWith(serviceToken);
|
||||
expect(smapiAuthTokens.issue).toHaveBeenCalledWith(refreshedServiceToken);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1128,8 +1133,11 @@ describe("wsdl api", () => {
|
||||
|
||||
describe("when existing auth token has not expired", () => {
|
||||
it("should return a refreshed auth token", async () => {
|
||||
const refreshedServiceToken = `refreshedServiceToken-${uuid()}`
|
||||
const newSmapiAuthToken = { token: `newToken-${uuid()}`, key: `newKey-${uuid()}` };
|
||||
|
||||
smapiAuthTokens.verify.mockReturnValue(E.right(serviceToken));
|
||||
musicService.refreshToken.mockReturnValue(TE.right({ serviceToken: refreshedServiceToken }));
|
||||
smapiAuthTokens.issue.mockReturnValue(newSmapiAuthToken);
|
||||
|
||||
const ws = await createClientAsync(`${service.uri}?wsdl`, {
|
||||
@@ -1148,6 +1156,9 @@ describe("wsdl api", () => {
|
||||
privateKey: newSmapiAuthToken.key
|
||||
},
|
||||
});
|
||||
|
||||
expect(musicService.refreshToken).toHaveBeenCalledWith(serviceToken);
|
||||
expect(smapiAuthTokens.issue).toHaveBeenCalledWith(refreshedServiceToken);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1325,13 +1336,14 @@ describe("wsdl api", () => {
|
||||
|
||||
describe("when token has expired", () => {
|
||||
it("should return a fault of Client.TokenRefreshRequired with a refreshAuthTokenResult", async () => {
|
||||
const expiry = dayjs().subtract(1, "d");
|
||||
const refreshedServiceToken = `refreshedServiceToken-${uuid()}`
|
||||
const newToken = {
|
||||
token: `newToken-${uuid()}`,
|
||||
key: `newKey-${uuid()}`
|
||||
};
|
||||
|
||||
smapiAuthTokens.verify.mockReturnValue(E.left(new ExpiredTokenError(serviceToken, expiry.unix())))
|
||||
smapiAuthTokens.verify.mockReturnValue(E.left(new ExpiredTokenError(serviceToken)))
|
||||
musicService.refreshToken.mockReturnValue(TE.right({ serviceToken: refreshedServiceToken }))
|
||||
smapiAuthTokens.issue.mockReturnValue(newToken)
|
||||
musicService.login.mockRejectedValue(
|
||||
"fail, should not call login!"
|
||||
@@ -1360,7 +1372,8 @@ describe("wsdl api", () => {
|
||||
});
|
||||
|
||||
expect(smapiAuthTokens.verify).toHaveBeenCalledWith(smapiAuthToken);
|
||||
expect(smapiAuthTokens.issue).toHaveBeenCalledWith(serviceToken);
|
||||
expect(musicService.refreshToken).toHaveBeenCalledWith(serviceToken);
|
||||
expect(smapiAuthTokens.issue).toHaveBeenCalledWith(refreshedServiceToken);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -177,8 +177,7 @@ describe("auth", () => {
|
||||
expect(result).toEqual(
|
||||
E.left(
|
||||
new ExpiredTokenError(
|
||||
authToken,
|
||||
tokenIssuedAt.add(30, "seconds").unix()
|
||||
authToken
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user