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',
})