diff --git a/src/link_codes.ts b/src/link_codes.ts index 12da39d..b0b6996 100644 --- a/src/link_codes.ts +++ b/src/link_codes.ts @@ -1,20 +1,39 @@ import { v4 as uuid } from 'uuid'; +export type Association = { + authToken: string + userId: string + nickname: string +} + export interface LinkCodes { mint(): string clear(): any count(): Number + has(linkCode: string): boolean + associate(linkCode: string, association: Association): any + associationFor(linkCode: string): Association | undefined } export class InMemoryLinkCodes implements LinkCodes { - linkCodes: Record = {} + linkCodes: Record = {} mint() { const linkCode = uuid(); - this.linkCodes[linkCode] = "" + this.linkCodes[linkCode] = undefined return linkCode } clear = () => { this.linkCodes = {} } count = () => Object.keys(this.linkCodes).length + has = (linkCode: string) => Object.keys(this.linkCodes).includes(linkCode) + associate = (linkCode: string, association: Association) => { + if(this.has(linkCode)) + this.linkCodes[linkCode] = association; + else + throw `Invalid linkCode ${linkCode}` + } + associationFor = (linkCode: string) => { + return this.linkCodes[linkCode]!; + } } diff --git a/src/music_service.ts b/src/music_service.ts index 7161f39..6153f66 100644 --- a/src/music_service.ts +++ b/src/music_service.ts @@ -8,13 +8,27 @@ const t = Md5.hashStr(`${process.env["BONOB_PASSWORD"]}${s}`); export type Credentials = { username: string, password: string } +export function isSuccess(authResult: AuthSuccess | AuthFailure): authResult is AuthSuccess { + return (authResult as AuthSuccess).authToken !== undefined; +} + +export type AuthSuccess = { + authToken: string + userId: string + nickname: string +} + +export type AuthFailure = { + message: string +} + export interface MusicService { - login(credentials: Credentials): boolean + login(credentials: Credentials): AuthSuccess | AuthFailure } export class Navidrome implements MusicService { - login(_: Credentials) { - return false + login({ username }: Credentials) { + return { authToken: `${username}`, userId: username, nickname: username } } ping = (): Promise => diff --git a/src/server.ts b/src/server.ts index 3f53cc7..28dc3c9 100644 --- a/src/server.ts +++ b/src/server.ts @@ -4,6 +4,7 @@ import { listen } from "soap"; import { readFileSync } from "fs"; import path from "path"; import morgan from "morgan"; +import crypto from "crypto"; import { Sonos, @@ -12,8 +13,8 @@ import { STRINGS_PATH, PRESENTATION_MAP_PATH, } from "./sonos"; -import { LinkCodes, InMemoryLinkCodes } from './link_codes' -import { MusicService } from './music_service' +import { LinkCodes, InMemoryLinkCodes } from "./link_codes"; +import { MusicService, isSuccess } from "./music_service"; import logger from "./logger"; const WSDL_FILE = path.resolve( @@ -31,7 +32,7 @@ function server( const app = express(); app.use(morgan("combined")); - app.use(express.urlencoded({ extended: false })); + app.use(express.urlencoded({ extended: false })); app.use(express.static("./web/public")); app.engine("eta", Eta.renderFile); @@ -65,16 +66,30 @@ function server( app.get("/login", (req, res) => { res.render("login", { bonobService, - linkCode: req.query.linkCode + linkCode: req.query.linkCode, }); }); app.post("/login", (req, res) => { - const canLogIn = musicService.login({ username: req.body.username, password: req.body.password}) - if(canLogIn) - res.send("ok") - else - res.send("boo") + const { username, password, linkCode } = req.body; + const authResult = musicService.login({ + username, + password, + }); + if (isSuccess(authResult)) { + if (linkCodes.has(linkCode)) { + linkCodes.associate(linkCode, authResult); + res.render("loginOK"); + } else { + res.status(400).render("failure", { + message: `Invalid linkCode!`, + }); + } + } else { + res.status(403).render("failure", { + message: `Login failed, ${authResult.message}!`, + }); + } }); app.get(STRINGS_PATH, (_, res) => { @@ -99,16 +114,45 @@ function server( return { getAppLinkResult: { authorizeAccount: { - appUrlStringId: 'AppLinkMessage', + appUrlStringId: "AppLinkMessage", deviceLink: { regUrl: `${webAddress}/login?linkCode=${linkCode}`, linkCode: linkCode, showLinkCode: false, }, }, - } + }, }; }, + getDeviceAuthToken: ({ linkCode }: { linkCode: string }) => { + const association = linkCodes.associationFor(linkCode); + if (association) { + return { + getDeviceAuthTokenResult: { + authToken: association.authToken, + privateKey: "v1", + userInfo: { + nickname: association.nickname, + userIdHashCode: crypto + .createHash("sha256") + .update(association.userId) + .digest("hex"), + }, + } + }; + } else { + throw { + Fault: { + faultcode: "Client.NOT_LINKED_RETRY", + faultstring: "Link Code not found retry...", + detail: { + ExceptionInfo: "NOT_LINKED_RETRY", + SonosError: "5" + } + } + } + } + }, getSessionId: ({ username, }: { @@ -134,16 +178,16 @@ function server( x.log = (type, data) => { switch (type) { case "info": - logger.info({ level:"info", data }); + logger.info({ level: "info", data }); break; case "warn": - logger.warn({ level:"warn", data }); + logger.warn({ level: "warn", data }); break; case "error": - logger.error({ level:"error", data }); + logger.error({ level: "error", data }); break; default: - logger.debug({ level:"debug", data }); + logger.debug({ level: "debug", data }); } }; diff --git a/tests/bonob_client.ts b/tests/bonob_client.ts index 761eccc..05a5395 100644 --- a/tests/bonob_client.ts +++ b/tests/bonob_client.ts @@ -5,7 +5,7 @@ import sonos, { bonobService } from "../src/sonos"; import server from "../src/server"; import logger from "../src/logger"; -import { InMemoryMusicService } from "builders"; +import { InMemoryMusicService } from "./in_memory_music_service"; const WEB_ADDRESS = "http://localhost:1234" diff --git a/tests/builders.ts b/tests/builders.ts index ba0130a..ca0c658 100644 --- a/tests/builders.ts +++ b/tests/builders.ts @@ -1,6 +1,5 @@ import { SonosDevice } from "@svrooij/sonos/lib"; import { v4 as uuid } from "uuid"; -import { MusicService, Credentials } from "../src/music_service"; import { Service, Device } from "../src/sonos"; @@ -55,23 +54,3 @@ export function getAppLinkMessage() { callbackPath: "", }; } - -export class InMemoryMusicService implements MusicService { - users: Record = {}; - - login({ username, password }: Credentials) { - return username != undefined && password != undefined && this.users[username] == password; - } - - hasUser(credentials: Credentials) { - this.users[credentials.username] = credentials.password; - } - - hasNoUsers() { - this.users = {}; - } - - clear() { - this.users = {}; - } -} diff --git a/tests/in_memory_music_service.ts b/tests/in_memory_music_service.ts new file mode 100644 index 0000000..5eb45cb --- /dev/null +++ b/tests/in_memory_music_service.ts @@ -0,0 +1,35 @@ +import { + MusicService, + Credentials, + AuthSuccess, + AuthFailure, +} from "../src/music_service"; + + +export class InMemoryMusicService implements MusicService { + users: Record = {}; + + login({ username, password }: Credentials): AuthSuccess | AuthFailure { + if ( + username != undefined && + password != undefined && + this.users[username] == password + ) { + return { authToken: "token123", userId: username, nickname: username }; + } else { + return { message: `Invalid user:${username}` }; + } + } + + hasUser(credentials: Credentials) { + this.users[credentials.username] = credentials.password; + } + + hasNoUsers() { + this.users = {}; + } + + clear() { + this.users = {}; + } +} diff --git a/tests/index.test.ts b/tests/index.test.ts index 03f4a98..cae9144 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -2,7 +2,8 @@ import request from "supertest"; import makeServer from "../src/server"; import { SONOS_DISABLED, Sonos, Device } from "../src/sonos"; -import { aDevice, aService, InMemoryMusicService } from './builders'; +import { aDevice, aService } from './builders'; +import { InMemoryMusicService } from "./in_memory_music_service"; describe("index", () => { describe("when sonos integration is disabled", () => { diff --git a/tests/link_codes.test.ts b/tests/link_codes.test.ts new file mode 100644 index 0000000..3f51927 --- /dev/null +++ b/tests/link_codes.test.ts @@ -0,0 +1,47 @@ +import { InMemoryLinkCodes } from "../src/link_codes" + +describe("InMemoryLinkCodes", () => { + const linkCodes = new InMemoryLinkCodes() + + describe('minting', () => { + it('should be able to mint unique codes', () => { + const code1 = linkCodes.mint() + const code2 = linkCodes.mint() + const code3 = linkCodes.mint() + + expect(code1).not.toEqual(code2); + expect(code1).not.toEqual(code3); + }); + }); + + describe("associating a code with a user", () => { + describe('when token is valid', () => { + it('should associate the token', () => { + const linkCode = linkCodes.mint(); + const association = { authToken: "token123", nickname: "bob", userId: "1" }; + + linkCodes.associate(linkCode, association); + + expect(linkCodes.associationFor(linkCode)).toEqual(association); + }); + }); + + describe('when token is valid', () => { + it('should throw an error', () => { + const invalidLinkCode = "invalidLinkCode"; + const association = { authToken: "token123", nickname: "bob", userId: "1" }; + + expect(() => linkCodes.associate(invalidLinkCode, association)).toThrow(`Invalid linkCode ${invalidLinkCode}`) + }); + }); + }); + + describe('fetching an association for a linkCode', () => { + describe('when the token doesnt exist', () => { + it('should return undefined', () => { + const missingLinkCode = 'someLinkCodeThatDoesntExist'; + expect(linkCodes.associationFor(missingLinkCode)).toBeUndefined() + }); + }) + }); +}) \ No newline at end of file diff --git a/tests/scenarios.test.ts b/tests/scenarios.test.ts index 3b32678..aba1985 100644 --- a/tests/scenarios.test.ts +++ b/tests/scenarios.test.ts @@ -4,7 +4,8 @@ import { Express } from "express"; import request from "supertest"; import { GetAppLinkResult } from "../src/smapi"; -import { InMemoryMusicService, getAppLinkMessage } from "./builders"; +import { getAppLinkMessage } from "./builders"; +import { InMemoryMusicService } from "./in_memory_music_service"; import { InMemoryLinkCodes } from "../src/link_codes"; import { Credentials } from "../src/music_service"; import makeServer from "../src/server"; @@ -53,13 +54,14 @@ class SonosDriver { .post(this.stripServiceRoot(regUrl)) .type("form") .send({ username, password, linkCode }) - .expect(200) .then(response => ({ expectSuccess: () => { - expect(response.text).toContain("ok") + expect(response.status).toEqual(200); + expect(response.text).toContain("Login Successful") }, expectFailure: () => { - expect(response.text).toContain("boo") + expect(response.status).toEqual(403); + expect(response.text).toContain("Login failed") }, })); }, diff --git a/tests/smapi.test.ts b/tests/smapi.test.ts index 258c54e..6ccd3ec 100644 --- a/tests/smapi.test.ts +++ b/tests/smapi.test.ts @@ -1,27 +1,228 @@ +import crypto from "crypto"; import request from "supertest"; +import { createClientAsync } from "soap"; -import { DOMParserImpl } from 'xmldom-ts'; -import * as xpath from 'xpath-ts'; +import { DOMParserImpl } from "xmldom-ts"; +import * as xpath from "xpath-ts"; +import { InMemoryLinkCodes, LinkCodes } from "../src/link_codes"; import makeServer from "../src/server"; -import { SONOS_DISABLED, STRINGS_PATH } from "../src/sonos"; +import { bonobService, SONOS_DISABLED, STRINGS_PATH } from "../src/sonos"; -import { aService, InMemoryMusicService } from './builders'; +import { aService, getAppLinkMessage } from "./builders"; +import { InMemoryMusicService } from "./in_memory_music_service"; +import supersoap from "./supersoap"; const parseXML = (value: string) => new DOMParserImpl().parseFromString(value); -const select = xpath.useNamespaces({"sonos": "http://sonos.com/sonosapi"}) +const select = xpath.useNamespaces({ sonos: "http://sonos.com/sonosapi" }); -describe('strings.xml', () => { - const server = makeServer(SONOS_DISABLED, aService(), 'http://localhost:1234', new InMemoryMusicService()); +describe("service config", () => { + describe("strings.xml", () => { + const server = makeServer( + SONOS_DISABLED, + aService(), + "http://localhost:1234", + new InMemoryMusicService() + ); - it("should return xml for the strings", async () => { - const res = await request(server).get(STRINGS_PATH).send(); + it("should return xml for the strings", async () => { + const res = await request(server).get(STRINGS_PATH).send(); - expect(res.status).toEqual(200); + expect(res.status).toEqual(200); - const xml = parseXML(res.text); - const x = select("//sonos:string[@stringId='AppLinkMessage']/text()", xml) as Node[] - expect(x.length).toEqual(1) - expect(x[0]!.nodeValue).toEqual("Linking sonos with bonob") + const xml = parseXML(res.text); + const x = select( + "//sonos:string[@stringId='AppLinkMessage']/text()", + xml + ) as Node[]; + expect(x.length).toEqual(1); + expect(x[0]!.nodeValue).toEqual("Linking sonos with bonob"); + }); }); -}); \ No newline at end of file +}); + +describe("api", () => { + const rootUrl = "http://localhost:1234"; + const service = bonobService("test-api", 133, rootUrl, "AppLink"); + const musicService = new InMemoryMusicService(); + const linkCodes = new InMemoryLinkCodes(); + + beforeEach(() => { + musicService.clear(); + linkCodes.clear(); + }); + + describe("pages", () => { + const server = makeServer( + SONOS_DISABLED, + service, + rootUrl, + musicService, + linkCodes + ); + + describe("/login", () => { + describe("when the credentials are valid", () => { + it("should return 200 ok and have associated linkCode with user", async () => { + const username = "jane"; + const password = "password100"; + const linkCode = linkCodes.mint(); + + musicService.hasUser({ username, password }); + + const res = await request(server) + .post("/login") + .type("form") + .send({ username, password, linkCode }) + .expect(200); + + expect(res.text).toContain("Login successful"); + + const association = linkCodes.associationFor(linkCode); + expect(association.nickname).toEqual(username); + }); + }); + + describe("when credentials are invalid", () => { + it("should return 403 with message", async () => { + const username = "userDoesntExist"; + const password = "password"; + const linkCode = linkCodes.mint(); + + musicService.hasNoUsers(); + + const res = await request(server) + .post("/login") + .type("form") + .send({ username, password, linkCode }) + .expect(403); + + expect(res.text).toContain(`Login failed, Invalid user:${username}`); + }); + }); + + describe("when linkCode is invalid", () => { + it("should return 400 with message", async () => { + const username = "jane"; + const password = "password100"; + const linkCode = "someLinkCodeThatDoesntExist"; + + musicService.hasUser({ username, password }); + + const res = await request(server) + .post("/login") + .type("form") + .send({ username, password, linkCode }) + .expect(400); + + expect(res.text).toContain("Invalid linkCode!"); + }); + }); + }); + }); + + describe("soap api", () => { + describe("getAppLink", () => { + const mockLinkCodes = { + mint: jest.fn(), + }; + const server = makeServer( + SONOS_DISABLED, + service, + rootUrl, + musicService, + (mockLinkCodes as unknown) as LinkCodes + ); + + it("should do something", async () => { + const ws = await createClientAsync(`${service.uri}?wsdl`, { + endpoint: service.uri, + httpClient: supersoap(server, rootUrl), + }); + + const linkCode = "theLinkCode8899"; + + mockLinkCodes.mint.mockReturnValue(linkCode); + + const result = await ws.getAppLinkAsync(getAppLinkMessage()); + + expect(result[0]).toEqual({ + getAppLinkResult: { + authorizeAccount: { + appUrlStringId: "AppLinkMessage", + deviceLink: { + regUrl: `${rootUrl}/login?linkCode=${linkCode}`, + linkCode: linkCode, + showLinkCode: false, + }, + }, + }, + }); + }); + }); + }); + + describe("getDeviceAuthToken", () => { + const linkCodes = new InMemoryLinkCodes(); + const server = makeServer( + SONOS_DISABLED, + service, + rootUrl, + musicService, + linkCodes + ); + + describe("when there is a linkCode association", () => { + it("should return a device auth token", async () => { + const linkCode = linkCodes.mint(); + const association = { authToken: "at", userId: "uid", nickname: "nn" }; + linkCodes.associate(linkCode, association); + + const ws = await createClientAsync(`${service.uri}?wsdl`, { + endpoint: service.uri, + httpClient: supersoap(server, rootUrl), + }); + + const result = await ws.getDeviceAuthTokenAsync({ linkCode }); + + expect(result[0]).toEqual({ + getDeviceAuthTokenResult: { + authToken: association.authToken, + privateKey: "v1", + userInfo: { + nickname: association.nickname, + userIdHashCode: crypto + .createHash("sha256") + .update(association.userId) + .digest("hex"), + }, + }, + }); + }); + }); + + describe("when there is no linkCode association", () => { + it("should return a device auth token", async () => { + const linkCode = "invalidLinkCode"; + + const ws = await createClientAsync(`${service.uri}?wsdl`, { + endpoint: service.uri, + httpClient: supersoap(server, rootUrl), + }); + + await ws + .getDeviceAuthTokenAsync({ linkCode }) + .then(() => { + throw "Shouldnt get here"; + }) + .catch((e: any) => { + expect(e.root.Envelope.Body.Fault).toEqual({ + faultcode: "Client.NOT_LINKED_RETRY", + faultstring: "Link Code not found retry...", + detail: { ExceptionInfo: "NOT_LINKED_RETRY", SonosError: "5" }, + }); + }); + }); + }); + }); +}); diff --git a/tests/ws.test.ts b/tests/ws.test.ts index 23301d7..5babe8a 100644 --- a/tests/ws.test.ts +++ b/tests/ws.test.ts @@ -1,7 +1,8 @@ import makeServer from "../src/server"; import { SONOS_DISABLED, SOAP_PATH } from "../src/sonos"; -import { aService, InMemoryMusicService } from "./builders"; +import { aService } from "./builders"; +import { InMemoryMusicService } from "./in_memory_music_service"; import supersoap from './supersoap'; import { createClientAsync } from "soap"; diff --git a/web/views/failure.eta b/web/views/failure.eta new file mode 100644 index 0000000..f975859 --- /dev/null +++ b/web/views/failure.eta @@ -0,0 +1,5 @@ +<% layout('./layout', { title: "Failure" }) %> + +
+

<%= it.message %>

+
\ No newline at end of file diff --git a/web/views/loginOK.eta b/web/views/loginOK.eta new file mode 100644 index 0000000..041121a --- /dev/null +++ b/web/views/loginOK.eta @@ -0,0 +1,5 @@ +<% layout('./layout', { title: "Login Successful" }) %> + +
+

Login successful

+
\ No newline at end of file