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 = {
|
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 {
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
lang,
|
params: {
|
||||||
message: lang("loginSuccessful"),
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
return res.status(403).render("failure", {
|
|
||||||
lang,
|
lang,
|
||||||
message: lang("loginFailed"),
|
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) => {
|
)().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) =>
|
||||||
authorization.match(/Bearer (?<token>.*)/),
|
pipe(
|
||||||
bearerToken,
|
authorization.match(/Bearer (?<token>.*)/),
|
||||||
E.map(match => match[1]!)
|
bearerToken,
|
||||||
)),
|
E.map((match) => match[1]!)
|
||||||
E.chain(bearerToken => pipe(
|
)
|
||||||
smapiAuthTokens.verify(smapiTokenFromString(bearerToken)),
|
),
|
||||||
E.mapLeft(_ => "Bearer token failed to verify")
|
E.chain((bearerToken) =>
|
||||||
)),
|
pipe(
|
||||||
|
smapiAuthTokens.verify(smapiTokenFromString(bearerToken)),
|
||||||
|
E.mapLeft((_) => "Bearer token failed to verify")
|
||||||
|
)
|
||||||
|
),
|
||||||
E.getOrElseW(() => undefined)
|
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 { 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.getOrElseW((fault) => {
|
||||||
E.fromPredicate(isSmapiRefreshTokenResultFault, (_) => fault),
|
throw fault.toSmapiFault();
|
||||||
E.map((it) => it.Fault.detail.refreshAuthTokenResult)
|
})
|
||||||
)
|
);
|
||||||
),
|
return pipe(
|
||||||
E.map((newToken) => ({
|
musicService.refreshToken(serviceToken),
|
||||||
refreshAuthTokenResult: {
|
TE.map((it) => smapiAuthTokens.issue(it.serviceToken)),
|
||||||
authToken: newToken.authToken,
|
TE.map((it) => ({
|
||||||
privateKey: newToken.privateKey,
|
refreshAuthTokenResult: {
|
||||||
},
|
authToken: it.token,
|
||||||
})),
|
privateKey: it.key,
|
||||||
E.getOrElseW((fault) => {
|
},
|
||||||
throw fault.toSmapiFault(smapiAuthTokens);
|
})),
|
||||||
})
|
TE.getOrElse((_) => {
|
||||||
),
|
throw SMAPI_FAULT_LOGIN_UNAUTHORIZED;
|
||||||
|
})
|
||||||
|
)();
|
||||||
|
},
|
||||||
getMediaURI: async (
|
getMediaURI: async (
|
||||||
{ id }: { id: string },
|
{ 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 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) =>
|
||||||
token: smapiToken.token,
|
b64Encode(
|
||||||
key: smapiToken.key
|
JSON.stringify({
|
||||||
}));
|
token: smapiToken.token,
|
||||||
export const smapiTokenFromString = (smapiTokenString: string): SmapiToken => JSON.parse(b64Decode(smapiTokenString));
|
key: smapiToken.key,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
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"));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
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 * 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 =>
|
||||||
coverArt,
|
pipe(
|
||||||
O.fromNullable,
|
coverArt,
|
||||||
O.map((it: string) => ({ system: "subsonic", resource: `art:${it}` })),
|
O.fromNullable,
|
||||||
O.getOrElseW(() => undefined)
|
O.map((it: string) => ({ system: "subsonic", resource: `art:${it}` })),
|
||||||
)
|
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);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user