server: add account creation and login

GitLab: #10
Change-Id: Iddd7ecee7210bc7b839cb2cec5d4291ac725d104
diff --git a/server/src/app.ts b/server/src/app.ts
index f126ed4..04cf04b 100644
--- a/server/src/app.ts
+++ b/server/src/app.ts
@@ -23,7 +23,7 @@
 import { AuthRouter } from './routers/auth-router.js';
 
 @Service()
-class App {
+export class App {
   constructor(private authRouter: AuthRouter) {}
 
   async build() {
@@ -47,5 +47,3 @@
     return app;
   }
 }
-
-export { App };
diff --git a/server/src/constants.ts b/server/src/constants.ts
index 5a1666b..1e50c68 100644
--- a/server/src/constants.ts
+++ b/server/src/constants.ts
@@ -16,7 +16,7 @@
  * <https://www.gnu.org/licenses/>.
  */
 
-enum StatusCode {
+export enum StatusCode {
   OK = 200,
   CREATED = 201,
   ACCEPTED = 202,
@@ -33,5 +33,3 @@
   INTERNAL_SERVER_ERROR = 500,
   NOT_IMPLEMENTED = 501,
 }
-
-export { StatusCode };
diff --git a/server/src/creds.ts b/server/src/creds.ts
new file mode 100644
index 0000000..9c43dc5
--- /dev/null
+++ b/server/src/creds.ts
@@ -0,0 +1,48 @@
+/*
+ * Copyright (C) 2022 Savoir-faire Linux Inc.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation; either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public
+ * License along with this program.  If not, see
+ * <https://www.gnu.org/licenses/>.
+ */
+import { readFile, writeFile } from 'node:fs/promises';
+
+import { Service } from 'typedi';
+
+@Service()
+export class Creds {
+  readonly file = 'creds.json';
+  db: Record<string, string>;
+
+  constructor() {
+    this.db = {};
+  }
+
+  async build() {
+    const buffer = await readFile(this.file).catch(() => Buffer.from('{}'));
+    this.db = JSON.parse(buffer.toString());
+    return this;
+  }
+
+  get(username: string) {
+    return this.db[username];
+  }
+
+  set(username: string, password: string) {
+    this.db[username] = password;
+  }
+
+  async save() {
+    await writeFile(this.file, JSON.stringify(this.db) + '\n');
+  }
+}
diff --git a/server/src/index.ts b/server/src/index.ts
index 661693f..3c40b28 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -23,12 +23,14 @@
 import { Container } from 'typedi';
 
 import { App } from './app.js';
+import { Creds } from './creds.js';
 import { Ws } from './ws.js';
 
 log.setLevel(process.env.NODE_ENV === 'production' ? 'error' : 'trace');
 
 const port: string | number = 5000;
 
+await Container.get(Creds).build();
 const app = await Container.get(App).build();
 const wss = await Container.get(Ws).build();
 
diff --git a/server/src/jamid.ts b/server/src/jamid.ts
new file mode 100644
index 0000000..34531cd
--- /dev/null
+++ b/server/src/jamid.ts
@@ -0,0 +1,258 @@
+/*
+ * Copyright (C) 2022 Savoir-faire Linux Inc.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation; either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public
+ * License along with this program.  If not, see
+ * <https://www.gnu.org/licenses/>.
+ */
+import log from 'loglevel';
+import { filter, firstValueFrom, Subject } from 'rxjs';
+import { Service } from 'typedi';
+
+import { itMap, itRange, itToArr, itToMap, require } from './utils.js';
+
+enum Bool {
+  False = 'false',
+  True = 'true',
+}
+
+interface SwigVec<T> {
+  size(): number;
+  get(i: number): T; // TODO: | undefined;
+}
+
+interface SwigMap<T, U> {
+  keys(): SwigVec<T>;
+  get(k: T): U; // TODO: | undefined;
+  set(k: T, v: U): void;
+}
+
+const swigVecToIt = <T>(v: SwigVec<T>) => itMap(itRange(0, v.size()), (i) => v.get(i));
+const swigMapToIt = <T, U>(m: SwigMap<T, U>) => itMap(swigVecToIt(m.keys()), (k): [T, U] => [k, m.get(k)]);
+
+// type IntVect = SwigVec<number>;
+// type UintVect = SwigVec<number>;
+// type FloatVect = SwigVec<number>;
+type StringVect = SwigVec<string>;
+// type IntegerMap = SwigMap<string, number>;
+type StringMap = SwigMap<string, string>;
+// type VectMap = SwigVec<StringMap>;
+// type Blob = SwigVec<number>;
+
+const stringVectToArr = (sv: StringVect) => itToArr(swigVecToIt(sv));
+const stringMapToMap = (sm: StringMap) => itToMap(swigMapToIt(sm));
+// const vectMapToJs = (vm: VectMap) => itToArr(itMap(swigVecToIt(vm), stringMapToMap));
+
+interface JamiSwig {
+  init(args: Record<string, unknown>): void;
+
+  // IntVect(): IntVect;
+  // UintVect(): UintVect;
+  // FloatVect(): FloatVect;
+  // StringVect(): StringVect;
+  // IntegerMap(): IntegerMap
+  // StringMap(): StringMap;
+  // VectMap(): VectMap;
+  // IntegerMap(): IntegerMap;
+
+  addAccount(details: StringMap): string;
+  removeAccount(id: string): void;
+
+  getAccountList(): StringVect;
+
+  registerName(id: string, password: string, username: string): boolean;
+  lookupName(id: string, nameserver: string, username: string): boolean;
+  lookupAddress(id: string, nameserver: string, address: string): boolean;
+
+  getAccountDetails(id: string): StringMap;
+  setAccountDetails(id: string, details: StringMap): void;
+  setAccountActive(id: string, active: Bool): void;
+}
+
+enum JamiSignal {
+  // using DRing::ConfigurationSignal;
+  AccountsChanged = 'AccountsChanged',
+  AccountDetailsChanged = 'AccountDetailsChanged',
+  RegistrationStateChanged = 'RegistrationStateChanged',
+  ContactAdded = 'ContactAdded',
+  ContactRemoved = 'ContactRemoved',
+  ExportOnRingEnded = 'ExportOnRingEnded',
+  NameRegistrationEnded = 'NameRegistrationEnded',
+  RegisteredNameFound = 'RegisteredNameFound',
+  VolatileDetailsChanged = 'VolatileDetailsChanged',
+  KnownDevicesChanged = 'KnownDevicesChanged',
+  IncomingAccountMessage = 'IncomingAccountMessage',
+  AccountMessageStatusChanged = 'AccountMessageStatusChanged',
+
+  // using DRing::CallSignal;
+  StateChange = 'StateChange',
+  IncomingMessage = 'IncomingMessage',
+  IncomingCall = 'IncomingCall',
+  IncomingCallWithMedia = 'IncomingCallWithMedia',
+  MediaChangeRequested = 'MediaChangeRequested',
+
+  // using DRing::ConversationSignal;
+  ConversationLoaded = 'ConversationLoaded',
+  MessagesFound = 'MessagesFound',
+  MessageReceived = 'MessageReceived',
+  ConversationProfileUpdated = 'ConversationProfileUpdated',
+  ConversationRequestReceived = 'ConversationRequestReceived',
+  ConversationRequestDeclined = 'ConversationRequestDeclined',
+  ConversationReady = 'ConversationReady',
+  ConversationRemoved = 'ConversationRemoved',
+  ConversationMemberEvent = 'ConversationMemberEvent',
+  OnConversationError = 'OnConversationError',
+  OnConferenceInfosUpdated = 'OnConferenceInfosUpdated',
+}
+
+interface VolatileDetailsChanged {
+  accountId: string;
+  details: Map<string, string>;
+}
+
+interface RegistrationStateChanged {
+  accountId: string;
+  state: string;
+  code: number;
+  details: string;
+}
+
+interface NameRegistrationEnded {
+  accountId: string;
+  state: number;
+  username: string;
+}
+
+interface RegisteredNameFound {
+  accountId: string;
+  state: number;
+  address: string;
+  username: string;
+}
+
+@Service()
+export class Jamid {
+  private readonly jamid: JamiSwig;
+  private readonly mapUsernameToAccountId: Map<string, string>;
+  private readonly events;
+
+  constructor() {
+    // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
+    this.jamid = require('../jamid.node') as JamiSwig;
+
+    const handlers: Record<string, unknown> = {};
+    const handler = (sig: string) => {
+      return (...args: unknown[]) => log.warn('Unhandled', sig, args);
+    };
+    Object.keys(JamiSignal).forEach((sig) => (handlers[sig] = handler(sig)));
+
+    const onVolatileDetailsChanged = new Subject<VolatileDetailsChanged>();
+    handlers.VolatileDetailsChanged = (accountId: string, details: Record<string, string>) =>
+      onVolatileDetailsChanged.next({ accountId, details: new Map(Object.entries(details)) });
+
+    const onRegistrationStateChanged = new Subject<RegistrationStateChanged>();
+    handlers.RegistrationStateChanged = (accountId: string, state: string, code: number, details: string) =>
+      onRegistrationStateChanged.next({ accountId, state, code, details });
+
+    const onNameRegistrationEnded = new Subject<NameRegistrationEnded>();
+    handlers.NameRegistrationEnded = (accountId: string, state: number, username: string) =>
+      onNameRegistrationEnded.next({ accountId, state, username });
+
+    const onRegisteredNameFound = new Subject<RegisteredNameFound>();
+    handlers.RegisteredNameFound = (accountId: string, state: number, address: string, username: string) =>
+      onRegisteredNameFound.next({ accountId, state, address, username });
+
+    this.events = {
+      onVolatileDetailsChanged: onVolatileDetailsChanged.asObservable(),
+      onRegistrationStateChanged: onRegistrationStateChanged.asObservable(),
+      onNameRegistrationEnded: onNameRegistrationEnded.asObservable(),
+      onRegisteredNameFound: onRegisteredNameFound.asObservable(),
+    };
+
+    this.events.onVolatileDetailsChanged.subscribe(({ accountId, details }) => {
+      log.debug('[1] Received onVolatileDetailsChanged with', { accountId, details });
+      // Keep map of usernames to account IDs as Jamid cannot do this by itself (AFAIK)
+      const username = details.get('Account.registeredName');
+      if (username) {
+        this.mapUsernameToAccountId.set(username, accountId);
+      }
+    });
+    this.events.onRegistrationStateChanged.subscribe((ctx) =>
+      log.debug('[1] Received onRegistrationStateChanged with', ctx)
+    );
+    this.events.onNameRegistrationEnded.subscribe((ctx) => log.debug('[1] Received onNameRegistrationEnded with', ctx));
+    this.events.onRegisteredNameFound.subscribe((ctx) => log.debug('[1] Received onRegisteredNameFound with', ctx));
+
+    this.mapUsernameToAccountId = new Map<string, string>();
+
+    // 1. You cannot change event handlers after init
+    // 2. You cannot specify multiple handlers for the same event
+    // 3. You cannot specify a default handler
+    // So we rely on the Subject() instead of Observable()
+    // Also, handlers receive multiple argument instead of tuple or object!
+    this.jamid.init(handlers);
+  }
+
+  getAccountList() {
+    return stringVectToArr(this.jamid.getAccountList());
+  }
+
+  async createAccount(details: Map<string, string | number | boolean>) {
+    // TODO: add proper typing directly into JamiSwig
+    const stringMapDetails: StringMap = new (this.jamid as any).StringMap();
+
+    stringMapDetails.set('Account.type', 'RING');
+    itMap(details.entries(), ([k, v]) => stringMapDetails.set('Account.' + k, v.toString()));
+
+    const id = this.jamid.addAccount(stringMapDetails);
+    return firstValueFrom(
+      this.events.onRegistrationStateChanged.pipe(
+        filter(({ accountId }) => accountId === id),
+        // TODO: is it the only state?
+        filter(({ state }) => state === 'REGISTERED')
+      )
+    );
+  }
+
+  destroyAccount(id: string) {
+    this.jamid.removeAccount(id);
+  }
+
+  async registerUsername(id: string, username: string, password: string) {
+    const hasRingNs = this.jamid.registerName(id, password, username);
+    if (!hasRingNs) {
+      log.error('Jami does not have NS');
+      throw new Error('Jami does not have NS');
+    }
+    return firstValueFrom(this.events.onNameRegistrationEnded.pipe(filter(({ accountId }) => accountId === id)));
+  }
+
+  // TODO: Ideally, we would fetch the username directly from Jami instead of
+  // keeping an internal map.
+  usernameToAccountId(username: string) {
+    return this.mapUsernameToAccountId.get(username);
+  }
+
+  async lookupUsername(username: string) {
+    const hasRingNs = this.jamid.lookupName('', '', username);
+    if (!hasRingNs) {
+      log.error('Jami does not have NS');
+      throw new Error('Jami does not have NS');
+    }
+    return firstValueFrom(this.events.onRegisteredNameFound.pipe(filter((r) => r.username === username)));
+  }
+
+  getAccountDetails(id: string) {
+    return stringMapToMap(this.jamid.getAccountDetails(id));
+  }
+}
diff --git a/server/src/routers/auth-router.ts b/server/src/routers/auth-router.ts
index dec19b3..b5afd36 100644
--- a/server/src/routers/auth-router.ts
+++ b/server/src/routers/auth-router.ts
@@ -15,35 +15,119 @@
  * License along with this program.  If not, see
  * <https://www.gnu.org/licenses/>.
  */
+import argon2 from 'argon2';
 import { Router } from 'express';
 import asyncHandler from 'express-async-handler';
+import { ParamsDictionary, Request } from 'express-serve-static-core';
+import { SignJWT } from 'jose';
+import log from 'loglevel';
 import { Service } from 'typedi';
 
 import { StatusCode } from '../constants.js';
+import { Creds } from '../creds.js';
+import { Jamid } from '../jamid.js';
+import { Vault } from '../vault.js';
+
+interface Credentials {
+  username?: string;
+  password?: string;
+}
 
 @Service()
-class AuthRouter {
+export class AuthRouter {
+  constructor(private readonly jamid: Jamid, private readonly creds: Creds, private readonly vault: Vault) {}
+
   async build() {
     const router = Router();
 
+    const privKey = await this.vault.privKey();
+
     router.post(
       '/new-account',
-      asyncHandler(async (_req, res, _next) => {
-        await Promise.resolve(42);
-        res.sendStatus(StatusCode.NOT_IMPLEMENTED);
+      asyncHandler(async (req: Request<ParamsDictionary, any, Credentials>, res, _next) => {
+        const { username, password } = req.body;
+        if (!username || !password) {
+          res.status(StatusCode.BAD_REQUEST).send('Missing username or password');
+          return;
+        }
+
+        const hashedPassword = await argon2.hash(password, { type: argon2.argon2id });
+
+        // TODO: add JAMS support
+        // managerUri: 'https://jams.savoirfairelinux.com',
+        // managerUsername: data.username,
+        // TODO: find a way to store the password directly in Jami
+        // Maybe by using the "password" field? But as I tested, it's not
+        // returned when getting user infos.
+        const { accountId } = await this.jamid.createAccount(new Map());
+
+        // TODO: understand why the password arg in this call must be empty
+        const { state } = await this.jamid.registerUsername(accountId, username, '');
+        if (state !== 0) {
+          this.jamid.destroyAccount(accountId);
+          if (state === 2) {
+            res.status(StatusCode.BAD_REQUEST).send('Invalid username or password');
+          } else if (state === 3) {
+            res.status(StatusCode.CONFLICT).send('Username already exists');
+          } else {
+            log.error(`POST - Unhandled state ${state}`);
+            res.sendStatus(StatusCode.INTERNAL_SERVER_ERROR);
+          }
+          return;
+        }
+
+        this.creds.set(username, hashedPassword);
+        await this.creds.save();
+
+        res.sendStatus(StatusCode.CREATED);
       })
     );
 
     router.post(
       '/login',
-      asyncHandler(async (_req, res, _next) => {
-        await Promise.resolve(42);
-        res.sendStatus(StatusCode.NOT_IMPLEMENTED);
+      asyncHandler(async (req: Request<ParamsDictionary, any, Credentials>, res, _next) => {
+        const { username, password } = req.body;
+        if (!username || !password) {
+          res.status(StatusCode.BAD_REQUEST).send('Missing username or password');
+          return;
+        }
+
+        // The account may either be:
+        // 1. not be found
+        // 2. found but not on this instance (but I'm not sure about this)
+        const accountId = this.jamid.usernameToAccountId(username);
+        if (accountId === undefined) {
+          res.status(StatusCode.NOT_FOUND).send('Username not found');
+          return;
+        }
+
+        // TODO: load the password from Jami
+        const hashedPassword = this.creds.get(username);
+        if (!hashedPassword) {
+          res.status(StatusCode.NOT_FOUND).send('Password not found');
+          return;
+        }
+
+        log.debug(this.jamid.getAccountDetails(accountId));
+
+        const isPasswordVerified = await argon2.verify(hashedPassword, password);
+        if (!isPasswordVerified) {
+          res.sendStatus(StatusCode.UNAUTHORIZED);
+          return;
+        }
+
+        const jwt = await new SignJWT({ id: accountId })
+          .setProtectedHeader({ alg: 'EdDSA' })
+          .setIssuedAt()
+          // TODO: use valid issuer and andiance
+          .setIssuer('urn:example:issuer')
+          .setAudience('urn:example:audience')
+          .setExpirationTime('2h')
+          .sign(privKey);
+        res.json({ accessToken: jwt });
       })
     );
 
     return router;
   }
 }
-
-export { AuthRouter };
diff --git a/server/src/utils.ts b/server/src/utils.ts
new file mode 100644
index 0000000..4f0b027
--- /dev/null
+++ b/server/src/utils.ts
@@ -0,0 +1,52 @@
+/*
+ * Copyright (C) 2022 Savoir-faire Linux Inc.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation; either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public
+ * License along with this program.  If not, see
+ * <https://www.gnu.org/licenses/>.
+ */
+import { createRequire } from 'node:module';
+
+export function* itRange(lo: number, hi: number) {
+  for (let i = lo; i < hi; ++i) {
+    yield i;
+  }
+}
+
+export function* itMap<T, U>(it: Iterable<T>, cb: (value: T, index: number) => U) {
+  let i = 0;
+  for (const item of it) {
+    yield cb(item, i++);
+  }
+}
+
+export function* itFilter<T>(it: Iterable<T>, cb: (value: T, index: number) => boolean) {
+  let i = 0;
+  for (const item of it) {
+    if (cb(item, i++)) {
+      yield item;
+    }
+  }
+}
+
+export const itToArr = <T>(it: Iterable<T>) => Array.from(it);
+
+export const itToMap = <T, U>(it: Iterable<[T, U]>) => {
+  const m = new Map<T, U>();
+  for (const [k, v] of it) {
+    m.set(k, v);
+  }
+  return m;
+};
+
+export const require = createRequire(import.meta.url);
diff --git a/server/src/vault.ts b/server/src/vault.ts
new file mode 100644
index 0000000..a0ae67c
--- /dev/null
+++ b/server/src/vault.ts
@@ -0,0 +1,34 @@
+/*
+ * Copyright (C) 2022 Savoir-faire Linux Inc.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation; either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public
+ * License along with this program.  If not, see
+ * <https://www.gnu.org/licenses/>.
+ */
+import { readFile } from 'node:fs/promises';
+
+import { importPKCS8, importSPKI } from 'jose';
+import { Service } from 'typedi';
+
+@Service()
+export class Vault {
+  async privKey() {
+    const privKeyBuf = await readFile('privkey.pem');
+    return importPKCS8(privKeyBuf.toString(), 'EdDSA');
+  }
+
+  async pubKey() {
+    const pubKeyBuf = await readFile('pubkey.pem');
+    return importSPKI(pubKeyBuf.toString(), 'EdDSA');
+  }
+}
diff --git a/server/src/ws.ts b/server/src/ws.ts
index f75260a..7670695 100644
--- a/server/src/ws.ts
+++ b/server/src/ws.ts
@@ -18,32 +18,54 @@
 import { IncomingMessage } from 'node:http';
 import { Duplex } from 'node:stream';
 
+import { jwtVerify } from 'jose';
+import log from 'loglevel';
 import { Service } from 'typedi';
+import { URL } from 'whatwg-url';
 import { WebSocket, WebSocketServer } from 'ws';
 
-@Service()
-class Ws {
-  async build() {
-    await Promise.resolve(42);
+import { Vault } from './vault.js';
 
-    const wss = new WebSocketServer({
-      noServer: true,
-    });
-    wss.on('connection', (ws: WebSocket, _req: IncomingMessage, id: string) => {
-      ws.on('message', (data) => {
-        ws.send(
-          JSON.stringify({
-            id,
-            data,
-          })
-        );
+@Service()
+export class Ws {
+  constructor(private readonly vault: Vault) {}
+
+  async build() {
+    const wss = new WebSocketServer({ noServer: true });
+    wss.on('connection', (ws: WebSocket, _req: IncomingMessage, accountId: string) => {
+      log.info('New connection', accountId);
+
+      ws.on('message', (_data) => {
+        ws.send(JSON.stringify({ accountId }));
       });
     });
 
+    const pubKey = await this.vault.pubKey();
+
     return (request: IncomingMessage, socket: Duplex, head: Buffer) => {
-      wss.handleUpgrade(request, socket, head, (ws) => wss.emit('connection', ws, request, '42'));
+      // Do not use parseURL because it returns a URLRecord and not a URL.
+      const url = new URL(request.url ?? '/', 'http://localhost/');
+      const accessToken = url.searchParams.get('accessToken');
+      if (!accessToken) {
+        socket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
+        socket.destroy();
+        return;
+      }
+
+      jwtVerify(accessToken, pubKey, {
+        issuer: 'urn:example:issuer',
+        audience: 'urn:example:audience',
+      })
+        .then(({ payload }) => {
+          const id = payload.id as string;
+          log.info('Authentication successful', id);
+          wss.handleUpgrade(request, socket, head, (ws) => wss.emit('connection', ws, request, id));
+        })
+        .catch((reason) => {
+          log.debug('Authentication failed', reason);
+          socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
+          socket.destroy();
+        });
     };
   }
 }
-
-export { Ws };