Refreshing bearer tokens when smapi token is refreshed (#85)

This commit is contained in:
Simon J
2021-12-09 14:41:52 +11:00
committed by GitHub
parent 7c0db619c9
commit 1c94654fb3
12 changed files with 606 additions and 637 deletions

View File

@@ -48,6 +48,7 @@ const subsonic = new Subsonic(
const featureFlagAwareMusicService: MusicService = { const featureFlagAwareMusicService: MusicService = {
generateToken: subsonic.generateToken, generateToken: subsonic.generateToken,
refreshToken: subsonic.refreshToken,
login: (serviceToken: string) => login: (serviceToken: string) =>
subsonic.login(serviceToken).then((library) => { subsonic.login(serviceToken).then((library) => {
return { return {

View File

@@ -1,27 +1,18 @@
import { BUrn } from "./burn"; import { BUrn } from "./burn";
import { taskEither as TE } from "fp-ts";
export type Credentials = { username: string; password: string }; 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 = { export type AuthSuccess = {
serviceToken: string; serviceToken: string;
userId: string; userId: string;
nickname: string; nickname: string;
}; };
export type AuthFailure = { export class AuthFailure extends Error {
message: string; constructor(message: string) {
super(message);
}
}; };
export type ArtistSummary = { export type ArtistSummary = {
@@ -155,7 +146,8 @@ export const asArtistAlbumPairs = (artists: Artist[]): [Artist, Album][] =>
); );
export interface MusicService { 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>; login(serviceToken: string): Promise<MusicLibrary>;
} }

View File

@@ -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 express, { Express, Request } from "express";
import * as Eta from "eta"; import * as Eta from "eta";
import path from "path"; import path from "path";
@@ -22,7 +22,7 @@ import {
ratingAsInt, ratingAsInt,
} from "./smapi"; } from "./smapi";
import { LinkCodes, InMemoryLinkCodes } from "./link_codes"; import { LinkCodes, InMemoryLinkCodes } from "./link_codes";
import { MusicService, isSuccess } from "./music_service"; import { MusicService, AuthFailure, AuthSuccess } from "./music_service";
import bindSmapiSoapServiceToExpress from "./smapi"; import bindSmapiSoapServiceToExpress from "./smapi";
import { APITokens, InMemoryAPITokens } from "./api_tokens"; import { APITokens, InMemoryAPITokens } from "./api_tokens";
import logger from "./logger"; import logger from "./logger";
@@ -36,7 +36,11 @@ import morgan from "morgan";
import { takeWithRepeats } from "./utils"; import { takeWithRepeats } from "./utils";
import { parse } from "./burn"; import { parse } from "./burn";
import { axiosImageFetcher, ImageFetcher } from "./subsonic"; 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"; export const BONOB_ACCESS_TOKEN_HEADER = "bat";
@@ -233,33 +237,36 @@ function server(
message: lang("invalidLinkCode"), message: lang("invalidLinkCode"),
}); });
} else { } else {
return musicService return pipe(
.generateToken({ musicService.generateToken({
username, username,
password, password,
}) }),
.then((authResult) => { TE.match(
if (isSuccess(authResult)) { (e: AuthFailure) => ({
linkCodes.associate(linkCode, authResult); status: 403,
return res.render("success", { template: "failure",
params: {
lang,
message: lang("loginFailed"),
cause: e.message,
},
}),
(success: AuthSuccess) => {
linkCodes.associate(linkCode, success);
return {
status: 200,
template: "success",
params: {
lang, lang,
message: lang("loginSuccessful"), message: lang("loginSuccessful"),
}); },
} else { };
return res.status(403).render("failure", {
lang,
message: lang("loginFailed"),
cause: authResult.message,
});
} }
}) )
.catch((e) => { )().then(({ status, template, params }) =>
return res.status(403).render("failure", { res.status(status).render(template, params)
lang, );
message: lang("loginFailed"),
cause: `Unexpected error occured - ${e}`,
});
});
} }
}); });
@@ -371,22 +378,26 @@ function server(
logger.info( logger.info(
`${trace} bnb<- ${req.method} ${req.path}?${JSON.stringify( `${trace} bnb<- ${req.method} ${req.path}?${JSON.stringify(
req.query req.query
)}, headers=${JSON.stringify({ ...req.headers, "authorization": "*****" })}` )}, headers=${JSON.stringify({ ...req.headers, authorization: "*****" })}`
); );
const authHeader = E.fromNullable("Missing header"); const authHeader = E.fromNullable("Missing header");
const bearerToken = E.fromNullable("No Bearer token"); const bearerToken = E.fromNullable("No Bearer token");
const serviceToken = pipe( const serviceToken = pipe(
authHeader(req.headers["authorization"] as string), authHeader(req.headers["authorization"] as string),
E.chain(authorization => pipe( E.chain((authorization) =>
pipe(
authorization.match(/Bearer (?<token>.*)/), authorization.match(/Bearer (?<token>.*)/),
bearerToken, bearerToken,
E.map(match => match[1]!) E.map((match) => match[1]!)
)), )
E.chain(bearerToken => pipe( ),
E.chain((bearerToken) =>
pipe(
smapiAuthTokens.verify(smapiTokenFromString(bearerToken)), smapiAuthTokens.verify(smapiTokenFromString(bearerToken)),
E.mapLeft(_ => "Bearer token failed to verify") E.mapLeft((_) => "Bearer token failed to verify")
)), )
),
E.getOrElseW(() => undefined) E.getOrElseW(() => undefined)
); );

View File

@@ -3,7 +3,7 @@ import { Express, Request } from "express";
import { listen } from "soap"; import { listen } from "soap";
import { readFileSync } from "fs"; import { readFileSync } from "fs";
import path from "path"; 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 { pipe } from "fp-ts/lib/function";
import logger from "./logger"; import logger from "./logger";
@@ -29,11 +29,12 @@ import { ICON, iconForGenre } from "./icon";
import _, { uniq } from "underscore"; import _, { uniq } from "underscore";
import { BUrn, formatForURL } from "./burn"; import { BUrn, formatForURL } from "./burn";
import { import {
InvalidTokenError, isExpiredTokenError,
isSmapiRefreshTokenResultFault,
MissingLoginTokenError, MissingLoginTokenError,
SmapiAuthTokens, SmapiAuthTokens,
smapiTokenAsString, smapiTokenAsString,
SMAPI_FAULT_LOGIN_UNAUTHORIZED,
ToSmapiFault,
} from "./smapi_auth"; } from "./smapi_auth";
export const LOGIN_ROUTE = "/login"; export const LOGIN_ROUTE = "/login";
@@ -379,6 +380,16 @@ type SoapyHeaders = {
credentials?: Credentials; credentials?: Credentials;
}; };
type Auth = {
serviceToken: string;
credentials: Credentials;
apiKey: string;
};
function isAuth(thing: any): thing is Auth {
return thing.serviceToken;
}
function bindSmapiSoapServiceToExpress( function bindSmapiSoapServiceToExpress(
app: Express, app: Express,
soapPath: string, 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()); const credentialsFrom = E.fromNullable(new MissingLoginTokenError());
return pipe( return pipe(
credentialsFrom(credentials), credentialsFrom(credentials),
@@ -424,21 +435,40 @@ function bindSmapiSoapServiceToExpress(
}; };
const login = async (credentials?: Credentials) => { const login = async (credentials?: Credentials) => {
const tokens = pipe( const authOrFail = pipe(
auth(credentials), auth(credentials),
E.getOrElseW((e) => { E.getOrElseW((fault) => fault)
throw e.toSmapiFault(smapiAuthTokens);
})
); );
if (isAuth(authOrFail)) {
return musicService return musicService
.login(tokens.serviceToken) .login(authOrFail.serviceToken)
.then((musicLibrary) => ({ ...tokens, musicLibrary })) .then((musicLibrary) => ({ ...authOrFail, musicLibrary }))
.catch((_) => { .catch((_) => {
throw new InvalidTokenError("Failed to login").toSmapiFault( throw SMAPI_FAULT_LOGIN_UNAUTHORIZED;
smapiAuthTokens
);
}); });
} 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( const soapyService = listen(
@@ -458,31 +488,34 @@ function bindSmapiSoapServiceToExpress(
pollInterval: 60, pollInterval: 60,
}, },
}), }),
refreshAuthToken: async (_, _2, soapyHeaders: SoapyHeaders) => refreshAuthToken: async (_, _2, soapyHeaders: SoapyHeaders) => {
pipe( const serviceToken = pipe(
auth(soapyHeaders?.credentials), auth(soapyHeaders?.credentials),
E.map(({ serviceToken }) => smapiAuthTokens.issue(serviceToken)), E.fold(
E.map((newToken) => ({ (fault) =>
authToken: newToken.token, isExpiredTokenError(fault)
privateKey: newToken.key, ? E.right(fault.expiredToken)
})), : E.left(fault),
E.orElse((fault) => (creds) => E.right(creds.serviceToken)
pipe(
fault.toSmapiFault(smapiAuthTokens),
E.fromPredicate(isSmapiRefreshTokenResultFault, (_) => fault),
E.map((it) => it.Fault.detail.refreshAuthTokenResult)
)
), ),
E.map((newToken) => ({ E.getOrElseW((fault) => {
throw fault.toSmapiFault();
})
);
return pipe(
musicService.refreshToken(serviceToken),
TE.map((it) => smapiAuthTokens.issue(it.serviceToken)),
TE.map((it) => ({
refreshAuthTokenResult: { refreshAuthTokenResult: {
authToken: newToken.authToken, authToken: it.token,
privateKey: newToken.privateKey, privateKey: it.key,
}, },
})), })),
E.getOrElseW((fault) => { TE.getOrElse((_) => {
throw fault.toSmapiFault(smapiAuthTokens); throw SMAPI_FAULT_LOGIN_UNAUTHORIZED;
}) })
), )();
},
getMediaURI: async ( getMediaURI: async (
{ id }: { id: string }, { id }: { id: string },
_, _,

View File

@@ -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 jwt from "jsonwebtoken";
import { v4 as uuid } from "uuid"; import { v4 as uuid } from "uuid";
import { b64Decode, b64Encode } from "./b64"; import { b64Decode, b64Encode } from "./b64";
import { Clock } from "./clock"; import { Clock } from "./clock";
export type SmapiFault = { Fault: { faultcode: string, faultstring: string } } export type SmapiFault = { Fault: { faultcode: string; faultstring: string } };
export type SmapiRefreshTokenResultFault = SmapiFault & { Fault: { detail: { refreshAuthTokenResult: { authToken: string, privateKey: string } }} } export type SmapiRefreshTokenResultFault = SmapiFault & {
Fault: {
detail: {
refreshAuthTokenResult: { authToken: string; privateKey: string };
};
};
};
function isError(thing: any): thing is Error { 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; return (fault.Fault as any).detail?.refreshAuthTokenResult != undefined;
} }
@@ -21,9 +29,25 @@ export type SmapiToken = {
}; };
export interface ToSmapiFault { 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 { export class MissingLoginTokenError extends Error implements ToSmapiFault {
_tag = "MissingLoginTokenError"; _tag = "MissingLoginTokenError";
@@ -31,12 +55,7 @@ export class MissingLoginTokenError extends Error implements ToSmapiFault {
super("Missing Login Token"); super("Missing Login Token");
} }
toSmapiFault = (_: SmapiAuthTokens) => ({ toSmapiFault = () => SMAPI_FAULT_LOGIN_UNSUPPORTED;
Fault: {
faultcode: "Client.LoginUnsupported",
faultstring: "Missing credentials...",
},
})
} }
@@ -47,66 +66,54 @@ export class InvalidTokenError extends Error implements ToSmapiFault {
super(message); super(message);
} }
toSmapiFault = (_: SmapiAuthTokens) => ({ toSmapiFault = () => SMAPI_FAULT_LOGIN_UNAUTHORIZED;
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,
},
},
}
};
}
} }
export function isExpiredTokenError(thing: any): thing is ExpiredTokenError { export function isExpiredTokenError(thing: any): thing is ExpiredTokenError {
return thing._tag == "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 = { export type SmapiAuthTokens = {
issue: (serviceToken: string) => SmapiToken; issue: (serviceToken: string) => SmapiToken;
verify: (smapiToken: SmapiToken) => Either<ToSmapiFault, string>; verify: (smapiToken: SmapiToken) => E.Either<ToSmapiFault, string>;
}; };
type TokenExpiredError = { type TokenExpiredError = {
name: string, name: string;
message: string, message: string;
expiredAt: number expiredAt: number;
} };
function isTokenExpiredError(thing: any): thing is TokenExpiredError { function isTokenExpiredError(thing: any): thing is TokenExpiredError {
return thing.name == 'TokenExpiredError'; return thing.name == "TokenExpiredError";
} }
export const smapiTokenAsString = (smapiToken: SmapiToken) => b64Encode(JSON.stringify({ export const smapiTokenAsString = (smapiToken: SmapiToken) =>
b64Encode(
JSON.stringify({
token: smapiToken.token, token: smapiToken.token,
key: smapiToken.key key: smapiToken.key,
})); })
export const smapiTokenFromString = (smapiTokenString: string): SmapiToken => JSON.parse(b64Decode(smapiTokenString)); );
export const smapiTokenFromString = (smapiTokenString: string): SmapiToken =>
JSON.parse(b64Decode(smapiTokenString));
export const SMAPI_TOKEN_VERSION = 2; export const SMAPI_TOKEN_VERSION = 2;
@@ -117,7 +124,13 @@ export class JWTSmapiLoginTokens implements SmapiAuthTokens {
private readonly version: number; private readonly version: number;
private readonly keyGenerator: () => string; 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.clock = clock;
this.secret = secret; this.secret = secret;
this.expiresIn = expiresIn; 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 { 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) { } catch (e) {
if(isTokenExpiredError(e)) { if (isTokenExpiredError(e)) {
const serviceToken = ((jwt.verify(smapiToken.token, this.secret + this.version + smapiToken.key, { ignoreExpiration: true })) as any).serviceToken; const serviceToken = (
return left(new ExpiredTokenError(serviceToken, e.expiredAt)) jwt.verify(
} else if(isError(e)) smapiToken.token,
return left(new InvalidTokenError(e.message)); this.secret + this.version + smapiToken.key,
else { ignoreExpiration: true }
return left(new InvalidTokenError("Failed to verify token")) ) 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"));
} }
}; };
} }

View File

@@ -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 * as A from "fp-ts/Array";
import { ordString } from "fp-ts/lib/Ord"; import { ordString } from "fp-ts/lib/Ord";
import { pipe } from "fp-ts/lib/function"; import { pipe } from "fp-ts/lib/function";
@@ -19,6 +19,7 @@ import {
Rating, Rating,
AlbumQueryType, AlbumQueryType,
Artist, Artist,
AuthFailure,
} from "./music_service"; } from "./music_service";
import sharp from "sharp"; import sharp from "sharp";
import _ from "underscore"; import _ from "underscore";
@@ -209,11 +210,11 @@ type GetStarredResponse = {
}; };
export type PingResponse = { export type PingResponse = {
status: string, status: string;
version: string, version: string;
type: string, type: string;
serverVersion: string serverVersion: string;
} };
type Search3Response = SubsonicResponse & { type Search3Response = SubsonicResponse & {
searchResult3: { searchResult3: {
@@ -234,12 +235,13 @@ type IdName = {
name: string; name: string;
}; };
const coverArtURN = (coverArt: string | undefined): BUrn | undefined => pipe( const coverArtURN = (coverArt: string | undefined): BUrn | undefined =>
pipe(
coverArt, coverArt,
O.fromNullable, O.fromNullable,
O.map((it: string) => ({ system: "subsonic", resource: `art:${it}` })), O.map((it: string) => ({ system: "subsonic", resource: `art:${it}` })),
O.getOrElseW(() => undefined) O.getOrElseW(() => undefined)
) );
export const artistImageURN = ( export const artistImageURN = (
spec: Partial<{ spec: Partial<{
@@ -255,7 +257,7 @@ export const artistImageURN = (
if (deets.artistImageURL && isValidImage(deets.artistImageURL)) { if (deets.artistImageURL && isValidImage(deets.artistImageURL)) {
return { return {
system: "external", system: "external",
resource: deets.artistImageURL resource: deets.artistImageURL,
}; };
} else if (artistIsInLibrary(deets.artistId)) { } else if (artistIsInLibrary(deets.artistId)) {
return { return {
@@ -279,7 +281,9 @@ export const asTrack = (album: Album, song: song): Track => ({
artist: { artist: {
id: song.artistId, id: song.artistId,
name: song.artist ? song.artist : "?", name: song.artist ? song.artist : "?",
image: song.artistId ? artistImageURN({ artistId: song.artistId }) : undefined, image: song.artistId
? artistImageURN({ artistId: song.artistId })
: undefined,
}, },
rating: { rating: {
love: song.starred != undefined, love: song.starred != undefined,
@@ -390,14 +394,21 @@ const AlbumQueryTypeToSubsonicType: Record<AlbumQueryType, string> = {
const artistIsInLibrary = (artistId: string | undefined) => const artistIsInLibrary = (artistId: string | undefined) =>
artistId != undefined && artistId != "-1"; 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 asToken = (credentials: SubsonicCredentials) =>
export const parseToken = (token: string): SubsonicCredentials => JSON.parse(b64Decode(token)); b64Encode(JSON.stringify(credentials));
export const parseToken = (token: string): SubsonicCredentials =>
JSON.parse(b64Decode(token));
interface SubsonicMusicLibrary extends MusicLibrary { interface SubsonicMusicLibrary extends MusicLibrary {
flavour(): string flavour(): string;
bearerToken(): Promise<string | undefined> bearerToken(
credentials: Credentials
): TE.TaskEither<Error, string | undefined>;
} }
export class Subsonic implements MusicService { export class Subsonic implements MusicService {
@@ -457,16 +468,40 @@ export class Subsonic implements MusicService {
else return json as unknown as T; else return json as unknown as T;
}); });
generateToken = async (credentials: Credentials) => generateToken = (credentials: Credentials) =>
this.getJSON<PingResponse>(credentials, "/rest/ping.view") pipe(
.then(({ type }) => this.libraryFor({ ...credentials, type }).then(library => ({ type, library }))) TE.tryCatch(
.then(({ library, type }) => library.bearerToken().then(bearer => ({ bearer, type }))) () =>
.then(({ bearer, type }) => ({ 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 }), serviceToken: asToken({ ...credentials, bearer, type }),
userId: credentials.username, userId: credentials.username,
nickname: credentials.username, nickname: credentials.username,
})) }))
.catch((e) => ({ message: `${e}` })); );
refreshToken = (serviceToken: string) =>
this.generateToken(parseToken(serviceToken));
getArtists = ( getArtists = (
credentials: Credentials credentials: Credentials
@@ -639,14 +674,16 @@ export class Subsonic implements MusicService {
// albums: it.album.map(asAlbum), // 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 subsonic = this;
const genericSubsonic: SubsonicMusicLibrary = { const genericSubsonic: SubsonicMusicLibrary = {
flavour: () => "subsonic", flavour: () => "subsonic",
bearerToken: () => Promise.resolve(undefined), bearerToken: (_: Credentials) => TE.right(undefined),
artists: (q: ArtistQuery): Promise<Result<ArtistSummary>> => artists: (q: ArtistQuery): Promise<Result<ArtistSummary>> =>
subsonic subsonic
.getArtists(credentials) .getArtists(credentials)
@@ -766,13 +803,7 @@ export class Subsonic implements MusicService {
Promise.resolve(coverArtURN) Promise.resolve(coverArtURN)
.then((it) => assertSystem(it, "subsonic")) .then((it) => assertSystem(it, "subsonic"))
.then((it) => it.resource.split(":")[1]!) .then((it) => it.resource.split(":")[1]!)
.then((it) => .then((it) => subsonic.getCoverArt(credentials, it, size))
subsonic.getCoverArt(
credentials,
it,
size
)
)
.then((res) => ({ .then((res) => ({
contentType: res.headers["content-type"], contentType: res.headers["content-type"],
data: Buffer.from(res.data, "binary"), 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({ return Promise.resolve({
...genericSubsonic, ...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 { } else {
return Promise.resolve(genericSubsonic); return Promise.resolve(genericSubsonic);
} }
} };
} }

View File

@@ -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 { InMemoryMusicService } from "./in_memory_music_service";
import { import {
AuthSuccess,
MusicLibrary, MusicLibrary,
artistToArtistSummary, artistToArtistSummary,
albumToAlbumSummary, albumToAlbumSummary,
@@ -28,7 +30,10 @@ describe("InMemoryMusicService", () => {
service.hasUser(credentials); 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.userId).toEqual(credentials.username);
expect(token.nickname).toEqual(credentials.username); expect(token.nickname).toEqual(credentials.username);
@@ -43,7 +48,10 @@ describe("InMemoryMusicService", () => {
service.hasUser(credentials); 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(); service.clear();
@@ -62,7 +70,11 @@ describe("InMemoryMusicService", () => {
service.hasUser(user); 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; musicLibrary = (await service.login(token.serviceToken)) as MusicLibrary;
}); });

View File

@@ -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 * as A from "fp-ts/Array";
import { fromEquals } from "fp-ts/lib/Eq"; import { fromEquals } from "fp-ts/lib/Eq";
import { pipe } from "fp-ts/lib/function"; import { pipe } from "fp-ts/lib/function";
@@ -34,23 +34,27 @@ export class InMemoryMusicService implements MusicService {
generateToken({ generateToken({
username, username,
password, password,
}: Credentials): Promise<AuthSuccess | AuthFailure> { }: Credentials): TE.TaskEither<AuthFailure, AuthSuccess> {
if ( if (
username != undefined && username != undefined &&
password != undefined && password != undefined &&
this.users[username] == password this.users[username] == password
) { ) {
return Promise.resolve({ return TE.right({
serviceToken: b64Encode(JSON.stringify({ username, password })), serviceToken: b64Encode(JSON.stringify({ username, password })),
userId: username, userId: username,
nickname: username, nickname: username,
type: "in-memory" type: "in-memory"
}); });
} else { } 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> { login(serviceToken: string): Promise<MusicLibrary> {
const credentials = JSON.parse(b64Decode(serviceToken)) as Credentials; const credentials = JSON.parse(b64Decode(serviceToken)) as Credentials;
if (this.users[credentials.username] != credentials.password) if (this.users[credentials.username] != credentials.password)

View File

@@ -3,10 +3,10 @@ import dayjs from "dayjs";
import request from "supertest"; import request from "supertest";
import Image from "image-js"; import Image from "image-js";
import fs from "fs"; 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 path from "path";
import { MusicService } from "../src/music_service"; import { AuthFailure, MusicService } from "../src/music_service";
import makeServer, { import makeServer, {
BONOB_ACCESS_TOKEN_HEADER, BONOB_ACCESS_TOKEN_HEADER,
RangeBytesFromFilter, RangeBytesFromFilter,
@@ -637,7 +637,7 @@ describe("server", () => {
}; };
linkCodes.has.mockReturnValue(true); linkCodes.has.mockReturnValue(true);
musicService.generateToken.mockResolvedValue(authSuccess); musicService.generateToken.mockReturnValue(TE.right(authSuccess))
linkCodes.associate.mockReturnValue(true); linkCodes.associate.mockReturnValue(true);
const res = await request(server) const res = await request(server)
@@ -669,7 +669,7 @@ describe("server", () => {
const message = `Invalid user:${username}`; const message = `Invalid user:${username}`;
linkCodes.has.mockReturnValue(true); linkCodes.has.mockReturnValue(true);
musicService.generateToken.mockResolvedValue({ message }); musicService.generateToken.mockReturnValue(TE.left(new AuthFailure(message)))
const res = await request(server) const res = await request(server)
.post(bonobUrl.append({ pathname: "/login" }).pathname()) .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", () => { describe("when linkCode is invalid", () => {
it("should return 400 with message", async () => { it("should return 400 with message", async () => {
const username = "jane"; const username = "jane";
@@ -777,7 +756,7 @@ describe("server", () => {
describe("when the Bearer token has expired", () => { describe("when the Bearer token has expired", () => {
it("should return a 401", async () => { 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( const res = await request(server).head(
bonobUrl bonobUrl
@@ -865,7 +844,7 @@ describe("server", () => {
describe("when the Bearer token has expired", () => { describe("when the Bearer token has expired", () => {
it("should return a 401", async () => { 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) const res = await request(server)
.get( .get(

View File

@@ -2,7 +2,7 @@ import crypto from "crypto";
import request from "supertest"; import request from "supertest";
import { Client, createClientAsync } from "soap"; import { Client, createClientAsync } from "soap";
import { v4 as uuid } from "uuid"; 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 { DOMParserImpl } from "xmldom-ts";
import * as xpath from "xpath-ts"; import * as xpath from "xpath-ts";
import { randomInt } from "crypto"; import { randomInt } from "crypto";
@@ -861,6 +861,7 @@ describe("defaultArtistArtURI", () => {
describe("wsdl api", () => { describe("wsdl api", () => {
const musicService = { const musicService = {
generateToken: jest.fn(), generateToken: jest.fn(),
refreshToken: jest.fn(),
login: jest.fn(), login: jest.fn(),
}; };
const linkCodes = { const linkCodes = {
@@ -1079,10 +1080,11 @@ describe("wsdl api", () => {
describe("when token has expired", () => { describe("when token has expired", () => {
it("should return a refreshed auth token", async () => { 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()}` }; 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); smapiAuthTokens.issue.mockReturnValue(newSmapiAuthToken);
const ws = await createClientAsync(`${service.uri}?wsdl`, { const ws = await createClientAsync(`${service.uri}?wsdl`, {
@@ -1101,6 +1103,9 @@ describe("wsdl api", () => {
privateKey: newSmapiAuthToken.key, 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", () => { describe("when existing auth token has not expired", () => {
it("should return a refreshed auth token", async () => { it("should return a refreshed auth token", async () => {
const refreshedServiceToken = `refreshedServiceToken-${uuid()}`
const newSmapiAuthToken = { token: `newToken-${uuid()}`, key: `newKey-${uuid()}` }; const newSmapiAuthToken = { token: `newToken-${uuid()}`, key: `newKey-${uuid()}` };
smapiAuthTokens.verify.mockReturnValue(E.right(serviceToken)); smapiAuthTokens.verify.mockReturnValue(E.right(serviceToken));
musicService.refreshToken.mockReturnValue(TE.right({ serviceToken: refreshedServiceToken }));
smapiAuthTokens.issue.mockReturnValue(newSmapiAuthToken); smapiAuthTokens.issue.mockReturnValue(newSmapiAuthToken);
const ws = await createClientAsync(`${service.uri}?wsdl`, { const ws = await createClientAsync(`${service.uri}?wsdl`, {
@@ -1148,6 +1156,9 @@ describe("wsdl api", () => {
privateKey: newSmapiAuthToken.key 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", () => { describe("when token has expired", () => {
it("should return a fault of Client.TokenRefreshRequired with a refreshAuthTokenResult", async () => { it("should return a fault of Client.TokenRefreshRequired with a refreshAuthTokenResult", async () => {
const expiry = dayjs().subtract(1, "d"); const refreshedServiceToken = `refreshedServiceToken-${uuid()}`
const newToken = { const newToken = {
token: `newToken-${uuid()}`, token: `newToken-${uuid()}`,
key: `newKey-${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) smapiAuthTokens.issue.mockReturnValue(newToken)
musicService.login.mockRejectedValue( musicService.login.mockRejectedValue(
"fail, should not call login!" "fail, should not call login!"
@@ -1360,7 +1372,8 @@ describe("wsdl api", () => {
}); });
expect(smapiAuthTokens.verify).toHaveBeenCalledWith(smapiAuthToken); expect(smapiAuthTokens.verify).toHaveBeenCalledWith(smapiAuthToken);
expect(smapiAuthTokens.issue).toHaveBeenCalledWith(serviceToken); expect(musicService.refreshToken).toHaveBeenCalledWith(serviceToken);
expect(smapiAuthTokens.issue).toHaveBeenCalledWith(refreshedServiceToken);
}); });
}); });
} }

View File

@@ -177,8 +177,7 @@ describe("auth", () => {
expect(result).toEqual( expect(result).toEqual(
E.left( E.left(
new ExpiredTokenError( new ExpiredTokenError(
authToken, authToken
tokenIssuedAt.add(30, "seconds").unix()
) )
) )
); );

File diff suppressed because it is too large Load Diff