Create JWT auth middleware and sample authenticated routes for /account

Changes:
- Create new middleware/auth.ts middleware to authenticate JWT
- Make vault.ts privateKey and publicKey fields to access them without await
- Remove @Service from auth router in auth-router.ts
- Create new AccountRouter with /account routes

Change-Id: Ie08651de7dbbce5d7596d80eba344707eb47d460
diff --git a/server/src/app.ts b/server/src/app.ts
index 04cf04b..2d4fd7d 100644
--- a/server/src/app.ts
+++ b/server/src/app.ts
@@ -15,23 +15,24 @@
  * License along with this program.  If not, see
  * <https://www.gnu.org/licenses/>.
  */
-import express, { NextFunction, Request, Response } from 'express';
+import express, { json, NextFunction, Request, Response } from 'express';
 import log from 'loglevel';
 import { Service } from 'typedi';
 
 import { StatusCode } from './constants.js';
-import { AuthRouter } from './routers/auth-router.js';
+import { accountRouter } from './routers/account-router.js';
+import { authRouter } from './routers/auth-router.js';
 
 @Service()
 export class App {
-  constructor(private authRouter: AuthRouter) {}
-
   async build() {
     const app = express();
 
+    app.use(json());
+
     // Setup routing
-    const authRouter = await this.authRouter.build();
     app.use('/auth', authRouter);
+    app.use('/account', accountRouter);
 
     // Setup 404 error handling
     app.use((_req, res) => {
diff --git a/server/src/constants.ts b/server/src/constants.ts
index 1e50c68..d73a292 100644
--- a/server/src/constants.ts
+++ b/server/src/constants.ts
@@ -15,7 +15,6 @@
  * License along with this program.  If not, see
  * <https://www.gnu.org/licenses/>.
  */
-
 export enum StatusCode {
   OK = 200,
   CREATED = 201,
diff --git a/server/src/index.ts b/server/src/index.ts
index 3c40b28..579e9ef 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -24,6 +24,7 @@
 
 import { App } from './app.js';
 import { Creds } from './creds.js';
+import { Vault } from './vault.js';
 import { Ws } from './ws.js';
 
 log.setLevel(process.env.NODE_ENV === 'production' ? 'error' : 'trace');
@@ -31,6 +32,7 @@
 const port: string | number = 5000;
 
 await Container.get(Creds).build();
+await Container.get(Vault).build();
 const app = await Container.get(App).build();
 const wss = await Container.get(Ws).build();
 
diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts
new file mode 100644
index 0000000..fad4165
--- /dev/null
+++ b/server/src/middleware/auth.ts
@@ -0,0 +1,50 @@
+/*
+ * 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 { NextFunction, Request, Response } from 'express';
+import { jwtVerify } from 'jose';
+import { Container } from 'typedi';
+
+import { StatusCode } from '../constants.js';
+import { Vault } from '../vault.js';
+
+export async function authenticateToken(req: Request, res: Response, next: NextFunction) {
+  const publicKey = Container.get(Vault).publicKey;
+
+  const authorizationHeader = req.headers.authorization;
+  if (!authorizationHeader) {
+    res.status(StatusCode.UNAUTHORIZED).send('Missing Authorization header');
+    return;
+  }
+
+  const token = authorizationHeader.split(' ')[1];
+  if (token === undefined) {
+    res.status(StatusCode.BAD_REQUEST).send('Missing JSON web token');
+    return;
+  }
+
+  try {
+    const { payload } = await jwtVerify(token, publicKey, {
+      issuer: 'urn:example:issuer',
+      audience: 'urn:example:audience',
+    });
+    res.locals.accountId = payload.id as string;
+    next();
+  } catch (err) {
+    res.sendStatus(StatusCode.UNAUTHORIZED);
+  }
+}
diff --git a/server/src/routers/account-router.ts b/server/src/routers/account-router.ts
new file mode 100644
index 0000000..38d0d80
--- /dev/null
+++ b/server/src/routers/account-router.ts
@@ -0,0 +1,37 @@
+/*
+ * 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 { Router } from 'express';
+import log from 'loglevel';
+import { Container } from 'typedi';
+
+import { Jamid } from '../jamid.js';
+import { authenticateToken } from '../middleware/auth.js';
+
+const jamid = Container.get(Jamid);
+
+export const accountRouter = Router();
+
+accountRouter.get('/', authenticateToken, (req, res) => {
+  log.debug('TODO: Implement jamid.getAccount()');
+  res.send(`TODO: ${req.method} ${req.originalUrl} for account ID ${res.locals.accountId}`);
+});
+
+accountRouter.post('/', authenticateToken, (req, res) => {
+  log.debug('TODO: Implement jamid.getAccount().updateDetails()');
+  res.send(`TODO: ${req.method} ${req.originalUrl} for account ID ${res.locals.accountId}`);
+});
diff --git a/server/src/routers/auth-router.ts b/server/src/routers/auth-router.ts
index b5afd36..26584f0 100644
--- a/server/src/routers/auth-router.ts
+++ b/server/src/routers/auth-router.ts
@@ -21,7 +21,7 @@
 import { ParamsDictionary, Request } from 'express-serve-static-core';
 import { SignJWT } from 'jose';
 import log from 'loglevel';
-import { Service } from 'typedi';
+import { Container } from 'typedi';
 
 import { StatusCode } from '../constants.js';
 import { Creds } from '../creds.js';
@@ -33,101 +33,93 @@
   password?: string;
 }
 
-@Service()
-export class AuthRouter {
-  constructor(private readonly jamid: Jamid, private readonly creds: Creds, private readonly vault: Vault) {}
+const jamid = Container.get(Jamid);
+const creds = Container.get(Creds);
+const vault = Container.get(Vault);
 
-  async build() {
-    const router = Router();
+export const authRouter = Router();
 
-    const privKey = await this.vault.privKey();
+authRouter.post(
+  '/new-account',
+  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;
+    }
 
-    router.post(
-      '/new-account',
-      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 });
 
-        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 jamid.createAccount(new Map());
 
-        // 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 jamid.registerUsername(accountId, username, '');
+    if (state !== 0) {
+      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 {
+        throw new Error(`Unhandled state ${state}`);
+      }
+      return;
+    }
 
-        // 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;
-        }
+    creds.set(username, hashedPassword);
+    await creds.save();
 
-        this.creds.set(username, hashedPassword);
-        await this.creds.save();
+    res.sendStatus(StatusCode.CREATED);
+  })
+);
 
-        res.sendStatus(StatusCode.CREATED);
-      })
-    );
+authRouter.post(
+  '/login',
+  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;
+    }
 
-    router.post(
-      '/login',
-      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 found
+    // 2. found but not on this instance (but I'm not sure about this)
+    const accountId = jamid.usernameToAccountId(username);
+    if (accountId === undefined) {
+      res.status(StatusCode.NOT_FOUND).send('Username not found');
+      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 = creds.get(username);
+    if (!hashedPassword) {
+      res.status(StatusCode.NOT_FOUND).send('Password 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(jamid.getAccountDetails(accountId));
 
-        log.debug(this.jamid.getAccountDetails(accountId));
+    const isPasswordVerified = await argon2.verify(hashedPassword, password);
+    if (!isPasswordVerified) {
+      res.sendStatus(StatusCode.UNAUTHORIZED);
+      return;
+    }
 
-        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;
-  }
-}
+    const jwt = await new SignJWT({ id: accountId })
+      .setProtectedHeader({ alg: 'EdDSA' })
+      .setIssuedAt()
+      // TODO: use valid issuer and audience
+      .setIssuer('urn:example:issuer')
+      .setAudience('urn:example:audience')
+      .setExpirationTime('2h')
+      .sign(vault.privateKey);
+    res.json({ accessToken: jwt });
+  })
+);
diff --git a/server/src/vault.ts b/server/src/vault.ts
index a0ae67c..45548db 100644
--- a/server/src/vault.ts
+++ b/server/src/vault.ts
@@ -17,18 +17,22 @@
  */
 import { readFile } from 'node:fs/promises';
 
-import { importPKCS8, importSPKI } from 'jose';
+import { importPKCS8, importSPKI, KeyLike } from 'jose';
 import { Service } from 'typedi';
 
 @Service()
 export class Vault {
-  async privKey() {
-    const privKeyBuf = await readFile('privkey.pem');
-    return importPKCS8(privKeyBuf.toString(), 'EdDSA');
-  }
+  privateKey!: KeyLike;
+  publicKey!: KeyLike;
 
-  async pubKey() {
-    const pubKeyBuf = await readFile('pubkey.pem');
-    return importSPKI(pubKeyBuf.toString(), 'EdDSA');
+  // TODO: Convert to environment variables and check if defined
+  async build() {
+    const privateKeyBuffer = await readFile('privkey.pem');
+    this.privateKey = await importPKCS8(privateKeyBuffer.toString(), 'EdDSA');
+
+    const publicKeyBuffer = await readFile('pubkey.pem');
+    this.publicKey = await importSPKI(publicKeyBuffer.toString(), 'EdDSA');
+
+    return this;
   }
 }
diff --git a/server/src/ws.ts b/server/src/ws.ts
index 7670695..74a7a06 100644
--- a/server/src/ws.ts
+++ b/server/src/ws.ts
@@ -40,19 +40,17 @@
       });
     });
 
-    const pubKey = await this.vault.pubKey();
-
     return (request: IncomingMessage, socket: Duplex, head: Buffer) => {
       // 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.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
         socket.destroy();
         return;
       }
 
-      jwtVerify(accessToken, pubKey, {
+      jwtVerify(accessToken, this.vault.publicKey, {
         issuer: 'urn:example:issuer',
         audience: 'urn:example:audience',
       })