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 = {
generateToken: subsonic.generateToken,
refreshToken: subsonic.refreshToken,
login: (serviceToken: string) =>
subsonic.login(serviceToken).then((library) => {
return {

View File

@@ -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>;
}

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 * 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", {
}),
TE.match(
(e: AuthFailure) => ({
status: 403,
template: "failure",
params: {
lang,
message: lang("loginFailed"),
cause: e.message,
},
}),
(success: AuthSuccess) => {
linkCodes.associate(linkCode, success);
return {
status: 200,
template: "success",
params: {
lang,
message: lang("loginSuccessful"),
});
} else {
return res.status(403).render("failure", {
lang,
message: lang("loginFailed"),
cause: authResult.message,
});
},
};
}
})
.catch((e) => {
return res.status(403).render("failure", {
lang,
message: lang("loginFailed"),
cause: `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(
E.chain((authorization) =>
pipe(
authorization.match(/Bearer (?<token>.*)/),
bearerToken,
E.map(match => match[1]!)
)),
E.chain(bearerToken => pipe(
E.map((match) => match[1]!)
)
),
E.chain((bearerToken) =>
pipe(
smapiAuthTokens.verify(smapiTokenFromString(bearerToken)),
E.mapLeft(_ => "Bearer token failed to verify")
)),
E.mapLeft((_) => "Bearer token failed to verify")
)
),
E.getOrElseW(() => undefined)
);

View File

@@ -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)
);
if (isAuth(authOrFail)) {
return musicService
.login(tokens.serviceToken)
.then((musicLibrary) => ({ ...tokens, musicLibrary }))
.login(authOrFail.serviceToken)
.then((musicLibrary) => ({ ...authOrFail, musicLibrary }))
.catch((_) => {
throw new InvalidTokenError("Failed to login").toSmapiFault(
smapiAuthTokens
);
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(
refreshAuthToken: async (_, _2, soapyHeaders: SoapyHeaders) => {
const serviceToken = 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.fold(
(fault) =>
isExpiredTokenError(fault)
? E.right(fault.expiredToken)
: E.left(fault),
(creds) => E.right(creds.serviceToken)
),
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: {
authToken: newToken.authToken,
privateKey: newToken.privateKey,
authToken: it.token,
privateKey: it.key,
},
})),
E.getOrElseW((fault) => {
throw fault.toSmapiFault(smapiAuthTokens);
TE.getOrElse((_) => {
throw SMAPI_FAULT_LOGIN_UNAUTHORIZED;
})
),
)();
},
getMediaURI: async (
{ 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 { 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({
export const smapiTokenAsString = (smapiToken: SmapiToken) =>
b64Encode(
JSON.stringify({
token: smapiToken.token,
key: smapiToken.key
}));
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;
@@ -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"))
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"));
}
};
}

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 { 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(
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"),
@@ -926,10 +957,22 @@ export class Subsonic implements MusicService {
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);
}
}
};
}

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 {
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;
});

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 { 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)

View File

@@ -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(

View File

@@ -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);
});
});
}

View File

@@ -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