diff --git a/src/access_tokens.ts b/src/access_tokens.ts index d46f7f5..96d2ee5 100644 --- a/src/access_tokens.ts +++ b/src/access_tokens.ts @@ -1,8 +1,10 @@ -import dayjs, { Dayjs } from 'dayjs'; -import { v4 as uuid } from 'uuid'; +import dayjs, { Dayjs } from "dayjs"; +import { v4 as uuid } from "uuid"; +import { Encryption } from "./encryption"; +import logger from "./logger"; export interface Clock { - now(): Dayjs + now(): Dayjs; } type AccessToken = { @@ -19,9 +21,9 @@ export interface AccessTokens { export class ExpiringAccessTokens implements AccessTokens { tokens = new Map(); clock: Clock; - - constructor(clock : Clock = { now: () => dayjs() }) { - this.clock = clock + + constructor(clock: Clock = { now: () => dayjs() }) { + this.clock = clock; } mint(authToken: string): string { @@ -29,7 +31,7 @@ export class ExpiringAccessTokens implements AccessTokens { const accessToken = { value: uuid(), authToken, - expiry: this.clock.now().add(12, 'hours') + expiry: this.clock.now().add(12, "hours"), }; this.tokens.set(accessToken.value, accessToken); return accessToken.value; @@ -41,10 +43,36 @@ export class ExpiringAccessTokens implements AccessTokens { } clearOutExpired() { - Array.from(this.tokens.values()).filter(it => it.expiry.isBefore(this.clock.now())).forEach(expired => { - this.tokens.delete(expired.value); - }) + Array.from(this.tokens.values()) + .filter((it) => it.expiry.isBefore(this.clock.now())) + .forEach((expired) => { + this.tokens.delete(expired.value); + }); } count = () => this.tokens.size; } + +export class EncryptedAccessTokens implements AccessTokens { + encryption: Encryption; + + constructor(encryption: Encryption) { + this.encryption = encryption; + } + + mint = (authToken: string): string => + Buffer.from(JSON.stringify(this.encryption.encrypt(authToken))).toString( + "base64" + ); + + authTokenFor(value: string): string | undefined { + try { + return this.encryption.decrypt( + JSON.parse(Buffer.from(value, "base64").toString("ascii")) + ); + } catch { + logger.warn("Failed to decrypt access token..."); + return undefined; + } + } +} diff --git a/src/server.ts b/src/server.ts index d92923e..286e6cf 100644 --- a/src/server.ts +++ b/src/server.ts @@ -13,7 +13,9 @@ import { import { LinkCodes, InMemoryLinkCodes } from "./link_codes"; import { MusicService, isSuccess } from "./music_service"; import bindSmapiSoapServiceToExpress from "./smapi"; -import { AccessTokens, ExpiringAccessTokens } from "./access_tokens"; +import { AccessTokens, EncryptedAccessTokens } from "./access_tokens"; +import encryption from "./encryption"; +import randomString from "./random_string"; export const BONOB_ACCESS_TOKEN_HEADER = "bonob-access-token"; @@ -23,7 +25,9 @@ function server( webAddress: string | "http://localhost:4534", musicService: MusicService, linkCodes: LinkCodes = new InMemoryLinkCodes(), - accessTokens: AccessTokens = new ExpiringAccessTokens() + accessTokens: AccessTokens = new EncryptedAccessTokens( + encryption(randomString()) + ) ): Express { const app = express(); @@ -158,20 +162,14 @@ function server( const size = Number.parseInt(req.params["size"]!); if (!authToken) { return res.status(401).send(); - } else if(type != "artist" && type != "album") { + } else if (type != "artist" && type != "album") { return res.status(400).send(); } else { return musicService .login(authToken) - .then((it) => - it.coverArt( - id, - type, - size - ) - ) + .then((it) => it.coverArt(id, type, size)) .then((coverArt) => { - if(coverArt) { + if (coverArt) { res.status(200); res.setHeader("content-type", coverArt.contentType); res.send(coverArt.data); diff --git a/tests/access_tokens.test.ts b/tests/access_tokens.test.ts index d5cb45a..e160623 100644 --- a/tests/access_tokens.test.ts +++ b/tests/access_tokens.test.ts @@ -1,16 +1,23 @@ -import { v4 as uuid } from 'uuid'; -import { ExpiringAccessTokens } from '../src/access_tokens'; -import dayjs from 'dayjs'; +import { v4 as uuid } from "uuid"; +import dayjs from "dayjs"; + +import { + EncryptedAccessTokens, + ExpiringAccessTokens, +} from "../src/access_tokens"; +import { Encryption } from "../src/encryption"; describe("ExpiringAccessTokens", () => { let now = dayjs(); - const accessTokens = new ExpiringAccessTokens({ now: () => now }) + const accessTokens = new ExpiringAccessTokens({ now: () => now }); describe("tokens", () => { it("they should be unique", () => { const authToken = uuid(); - expect(accessTokens.mint(authToken)).not.toEqual(accessTokens.mint(authToken)); + expect(accessTokens.mint(authToken)).not.toEqual( + accessTokens.mint(authToken) + ); }); }); @@ -35,73 +42,139 @@ describe("ExpiringAccessTokens", () => { const accessToken1 = accessTokens.mint(authToken); const accessToken2 = accessTokens.mint(authToken); - expect(accessTokens.authTokenFor(accessToken1)).toEqual(authToken); expect(accessTokens.authTokenFor(accessToken2)).toEqual(authToken); }); }); - describe('tokens that have expired', () => { + describe("tokens that have expired", () => { describe("retrieving it", () => { it("should return undefined", () => { const authToken = uuid(); now = dayjs(); const accessToken = accessTokens.mint(authToken); - - now = now.add(12, 'hours').add(1, 'second'); - expect(accessTokens.authTokenFor(accessToken)).toBeUndefined() + now = now.add(12, "hours").add(1, "second"); + + expect(accessTokens.authTokenFor(accessToken)).toBeUndefined(); }); }); describe("should be cleared out", () => { - const authToken1 = uuid(); - const authToken2 = uuid(); + const authToken1 = uuid(); + const authToken2 = uuid(); - now = dayjs(); + now = dayjs(); - const accessToken1_1 = accessTokens.mint(authToken1); - const accessToken2_1 = accessTokens.mint(authToken2); + const accessToken1_1 = accessTokens.mint(authToken1); + const accessToken2_1 = accessTokens.mint(authToken2); - expect(accessTokens.count()).toEqual(2); - expect(accessTokens.authTokenFor(accessToken1_1)).toEqual(authToken1); - expect(accessTokens.authTokenFor(accessToken2_1)).toEqual(authToken2); + expect(accessTokens.count()).toEqual(2); + expect(accessTokens.authTokenFor(accessToken1_1)).toEqual(authToken1); + expect(accessTokens.authTokenFor(accessToken2_1)).toEqual(authToken2); - now = now.add(12, 'hours').add(1, 'second'); + now = now.add(12, "hours").add(1, "second"); - const accessToken1_2 = accessTokens.mint(authToken1); + const accessToken1_2 = accessTokens.mint(authToken1); - expect(accessTokens.count()).toEqual(1); - expect(accessTokens.authTokenFor(accessToken1_1)).toBeUndefined(); - expect(accessTokens.authTokenFor(accessToken2_1)).toBeUndefined(); - expect(accessTokens.authTokenFor(accessToken1_2)).toEqual(authToken1); + expect(accessTokens.count()).toEqual(1); + expect(accessTokens.authTokenFor(accessToken1_1)).toBeUndefined(); + expect(accessTokens.authTokenFor(accessToken2_1)).toBeUndefined(); + expect(accessTokens.authTokenFor(accessToken1_2)).toEqual(authToken1); - now = now.add(6, 'hours'); + now = now.add(6, "hours"); - const accessToken2_2 = accessTokens.mint(authToken2); + const accessToken2_2 = accessTokens.mint(authToken2); - expect(accessTokens.count()).toEqual(2); - expect(accessTokens.authTokenFor(accessToken1_1)).toBeUndefined(); - expect(accessTokens.authTokenFor(accessToken2_1)).toBeUndefined(); - expect(accessTokens.authTokenFor(accessToken1_2)).toEqual(authToken1); - expect(accessTokens.authTokenFor(accessToken2_2)).toEqual(authToken2); + expect(accessTokens.count()).toEqual(2); + expect(accessTokens.authTokenFor(accessToken1_1)).toBeUndefined(); + expect(accessTokens.authTokenFor(accessToken2_1)).toBeUndefined(); + expect(accessTokens.authTokenFor(accessToken1_2)).toEqual(authToken1); + expect(accessTokens.authTokenFor(accessToken2_2)).toEqual(authToken2); - now = now.add(6, 'hours').add(1, 'minute'); + now = now.add(6, "hours").add(1, "minute"); - expect(accessTokens.authTokenFor(accessToken1_1)).toBeUndefined(); - expect(accessTokens.authTokenFor(accessToken2_1)).toBeUndefined(); - expect(accessTokens.authTokenFor(accessToken1_2)).toBeUndefined(); - expect(accessTokens.authTokenFor(accessToken2_2)).toEqual(authToken2); - expect(accessTokens.count()).toEqual(1); - - now = now.add(6, 'hours').add(1, 'minute'); + expect(accessTokens.authTokenFor(accessToken1_1)).toBeUndefined(); + expect(accessTokens.authTokenFor(accessToken2_1)).toBeUndefined(); + expect(accessTokens.authTokenFor(accessToken1_2)).toBeUndefined(); + expect(accessTokens.authTokenFor(accessToken2_2)).toEqual(authToken2); + expect(accessTokens.count()).toEqual(1); - expect(accessTokens.authTokenFor(accessToken1_1)).toBeUndefined(); - expect(accessTokens.authTokenFor(accessToken2_1)).toBeUndefined(); - expect(accessTokens.authTokenFor(accessToken1_2)).toBeUndefined(); - expect(accessTokens.authTokenFor(accessToken2_2)).toBeUndefined(); - expect(accessTokens.count()).toEqual(0); + now = now.add(6, "hours").add(1, "minute"); + + expect(accessTokens.authTokenFor(accessToken1_1)).toBeUndefined(); + expect(accessTokens.authTokenFor(accessToken2_1)).toBeUndefined(); + expect(accessTokens.authTokenFor(accessToken1_2)).toBeUndefined(); + expect(accessTokens.authTokenFor(accessToken2_2)).toBeUndefined(); + expect(accessTokens.count()).toEqual(0); }); - }) -}) \ No newline at end of file + }); +}); + +describe("EncryptedAccessTokens", () => { + const encryption = { + encrypt: jest.fn(), + decrypt: jest.fn(), + }; + + const accessTokens = new EncryptedAccessTokens( + (encryption as unknown) as Encryption + ); + + beforeEach(() => { + jest.clearAllMocks(); + jest.resetAllMocks(); + }); + + describe("encrypt and decrypt", () => { + it("should be able to round trip the token", () => { + const authToken = `the token - ${uuid()}`; + const hash = { + encryptedData: "the encrypted token", + iv: "vi", + }; + + encryption.encrypt.mockReturnValue(hash); + encryption.decrypt.mockReturnValue(authToken); + + const accessToken = accessTokens.mint(authToken); + + expect(accessToken).not.toContain(authToken); + expect(accessToken).toEqual( + Buffer.from(JSON.stringify(hash)).toString("base64") + ); + + expect(accessTokens.authTokenFor(accessToken)).toEqual(authToken); + + expect(encryption.encrypt).toHaveBeenCalledWith(authToken); + expect(encryption.decrypt).toHaveBeenCalledWith(hash); + }); + }); + + describe("when the token is a valid Hash but doesnt decrypt", () => { + it("should return undefined", () => { + const hash = { + encryptedData: "valid hash", + iv: "vi", + }; + encryption.decrypt.mockImplementation(() => { + throw "Boooooom decryption failed!!!"; + }); + expect( + accessTokens.authTokenFor( + Buffer.from(JSON.stringify(hash)).toString("base64") + ) + ).toBeUndefined(); + }); + }); + + describe("when the token is not even a valid hash", () => { + it("should return undefined", () => { + encryption.decrypt.mockImplementation(() => { + throw "Boooooom decryption failed!!!"; + }); + expect(accessTokens.authTokenFor("some rubbish")).toBeUndefined(); + }); + }); +});